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
@@ -94,6 +94,12 @@ public interface ITaskRepository
/// </summary>
Task<bool> ClearBudgetMissAsync(string id, DateTime now, CancellationToken ct);
/// <summary>
/// #717: Records that a human was asked about fill work that found no room before its
/// deadline. Returns <c>false</c> if that had already happened — asked once.
/// </summary>
Task<bool> MarkFillAlertedAsync(string id, DateTime now, CancellationToken ct);
/// <summary>
/// Reconciliation beim Start: verwaiste Claims (älter als <paramref name="leaseCutoff"/>)
/// lösen und die betroffenen Aufgaben von <see cref="TaskItemStatus.InProgress"/> zurück
@@ -108,5 +114,8 @@ public interface ITaskRepository
/// </summary>
Task<IReadOnlyList<TaskItem>> ListBlockedByAsync(string blockerId, CancellationToken ct);
/// <summary>#717: The tasks directly below a task in the goal chain (#714), archived ones included.</summary>
Task<IReadOnlyList<TaskItem>> ListChildrenAsync(string parentId, CancellationToken ct);
Task<int> CountAsync(CancellationToken ct);
}
@@ -34,11 +34,11 @@ public sealed class SqliteTaskRepository : ITaskRepository
INSERT INTO Tasks
(Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz,
RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName,
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, CreatedBy, ParentId, OriginRunId, OriginTaskId)
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, CreatedBy, ParentId, OriginRunId, OriginTaskId, FillBy, EstimateUsd)
VALUES
(@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz,
@requireApproval, @acceptance, @blockedBy, @market, @body, @fileName,
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt, @createdBy, @parentId, @originRun, @originTask);
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt, @createdBy, @parentId, @originRun, @originTask, @fillBy, @estimateUsd);
""";
BindDefinition(insert, task);
insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status));
@@ -64,6 +64,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
ParentId = @parentId,
OriginRunId = CASE WHEN @originRun = '' THEN OriginRunId ELSE @originRun END,
OriginTaskId = CASE WHEN @originTask = '' THEN OriginTaskId ELSE @originTask END,
FillBy = @fillBy, EstimateUsd = @estimateUsd,
UpdatedAt = @updatedAt
WHERE Id = @id
""";
@@ -259,6 +260,17 @@ public sealed class SqliteTaskRepository : ITaskRepository
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<bool> MarkFillAlertedAsync(string id, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE Tasks SET FillAlertedAt = @now WHERE Id = @id AND FillAlertedAt IS NULL";
cmd.Parameters.AddWithValue("@now", Format(now));
cmd.Parameters.AddWithValue("@id", id);
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<int> ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
@@ -321,6 +333,21 @@ public sealed class SqliteTaskRepository : ITaskRepository
return results;
}
public async Task<IReadOnlyList<TaskItem>> ListChildrenAsync(string parentId, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT " + Columns + " FROM Tasks WHERE ParentId = @parent COLLATE NOCASE";
cmd.Parameters.AddWithValue("@parent", parentId.Trim());
var results = new List<TaskItem>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
results.Add(Read(reader));
return results;
}
public async Task<int> CountAsync(CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
@@ -335,7 +362,8 @@ public sealed class SqliteTaskRepository : ITaskRepository
"Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " +
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, "
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy, ParentId, OriginRunId, OriginTaskId";
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy, ParentId, OriginRunId, OriginTaskId, "
+ "FillBy, EstimateUsd, FillAlertedAt";
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
{
@@ -361,6 +389,10 @@ public sealed class SqliteTaskRepository : ITaskRepository
// #697: Stamped once; a file written by hand, or re-imported, keeps what is known.
cmd.Parameters.AddWithValue("@originRun", task.OriginRunId ?? "");
cmd.Parameters.AddWithValue("@originTask", task.OriginTaskId ?? "");
// #717: Part of the definition — the file is the truth, so a file without it clears it.
cmd.Parameters.AddWithValue("@fillBy", task.FillBy is { } fillBy ? Format(fillBy) : DBNull.Value);
cmd.Parameters.AddWithValue("@estimateUsd",
task.EstimateUsd is { } usd ? usd.ToString(System.Globalization.CultureInfo.InvariantCulture) : DBNull.Value);
}
private static async Task<TaskItem?> ReadAsync(SqliteConnection conn, string id, CancellationToken ct)
@@ -415,7 +447,11 @@ public sealed class SqliteTaskRepository : ITaskRepository
CreatedBy = r.GetString(26),
ParentId = r.GetString(27),
OriginRunId = r.GetString(28),
OriginTaskId = r.GetString(29)
OriginTaskId = r.GetString(29),
FillBy = r.IsDBNull(30) ? null : Parse(r.GetString(30)),
EstimateUsd = r.IsDBNull(31) ? null
: decimal.Parse(r.GetString(31), System.Globalization.CultureInfo.InvariantCulture),
FillAlertedAt = r.IsDBNull(32) ? null : Parse(r.GetString(32))
};
}
@@ -156,6 +156,8 @@ public static class TaskFrontmatter
ParentId = scalars.GetValueOrDefault("parent", ""),
OriginRunId = scalars.GetValueOrDefault("origin_run", ""),
OriginTaskId = scalars.GetValueOrDefault("origin_task", ""),
FillBy = ParseUtc(scalars.GetValueOrDefault("fill_by")),
EstimateUsd = ParseUsd(scalars.GetValueOrDefault("estimate_usd")),
Body = body
};
return true;
@@ -182,6 +184,12 @@ public static class TaskFrontmatter
if (!string.IsNullOrWhiteSpace(task.OriginTaskId))
sb.Append($"origin_task: {Scalar(task.OriginTaskId)}\n");
// #717: Fill work — a deadline instead of an appointment — and the author's estimate.
if (task.FillBy is { } fillBy)
sb.Append($"fill_by: {fillBy.ToUniversalTime():yyyy-MM-ddTHH:mm:ssZ}\n");
if (task.EstimateUsd is { } estimate)
sb.Append($"estimate_usd: {estimate.ToString(System.Globalization.CultureInfo.InvariantCulture)}\n");
if (task.When is { } w)
{
sb.Append("when:\n");
@@ -320,6 +328,31 @@ public static class TaskFrontmatter
private static int ParsePriority(string? value)
=> int.TryParse(value?.Trim(), out var p) ? Math.Clamp(p, 1, 5) : 3;
/// <summary>
/// #717: A point in time, read as UTC unless it says otherwise. A bare date means the end
/// of that day — "by the 30th" includes the 30th. Unreadable is no deadline, not an error.
/// </summary>
public static DateTime? ParseUtc(string? value)
{
var v = value?.Trim();
if (string.IsNullOrEmpty(v))
return null;
var styles = System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal;
if (DateOnly.TryParseExact(v, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var day))
return DateTime.SpecifyKind(day.AddDays(1).ToDateTime(TimeOnly.MinValue), DateTimeKind.Utc);
return DateTime.TryParse(v, System.Globalization.CultureInfo.InvariantCulture, styles, out var at)
? DateTime.SpecifyKind(at, DateTimeKind.Utc)
: null;
}
/// <summary>#717: An amount in USD; negative or unreadable is no estimate.</summary>
public static decimal? ParseUsd(string? value)
=> decimal.TryParse(value?.Trim().TrimStart('$'), System.Globalization.NumberStyles.Number,
System.Globalization.CultureInfo.InvariantCulture, out var usd) && usd >= 0
? usd
: null;
private static string EmptyToDefault(string? value, string fallback)
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
+25
View File
@@ -252,6 +252,31 @@ public sealed record TaskItem
/// <summary>#697: The task that run was working on, if any — the task this one arose from.</summary>
public string OriginTaskId { get; init; } = "";
// ─── Fill work and estimates (#717) ───
/// <summary>
/// #717: Fill work — "at the latest by" instead of an appointment (UTC). The task runs when
/// the day's budget has room that would otherwise lapse, and before this point at the
/// latest. Only for one-time tasks; a recurring task has its appointments.
/// </summary>
public DateTime? FillBy { get; init; }
/// <summary>
/// #717: What the task's author expects one run to cost, in USD — a guess, and marked as
/// one. Used only while there is nothing measured: the task's own runs, or those of its
/// siblings, replace it (see <see cref="Budget.TaskCostEstimator"/>).
/// </summary>
public decimal? EstimateUsd { get; init; }
/// <summary>
/// #717: When a human was asked because the fill work found no room before its deadline.
/// Asked once; execution state, not part of the file.
/// </summary>
public DateTime? FillAlertedAt { get; init; }
/// <summary>#717: One-time work with a deadline instead of an appointment.</summary>
public bool IsFillWork => FillBy is not null && !IsRecurring;
/// <summary>A one-time task that was refused for lack of budget and carried over.</summary>
public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0;
+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.