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)
{
+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);
}
@@ -147,6 +147,10 @@ public sealed class InstanceConfig
[JsonPropertyName("cases")]
public Cases.CaseSettings Cases { get; set; } = new();
/// <summary>#717: When unused budget is filled, and when a task counts as large.</summary>
[JsonPropertyName("fillWork")]
public Budget.FillWorkSettings FillWork { get; set; } = new();
[JsonPropertyName("maxDelegationDepth")]
public int MaxDelegationDepth { get; set; } = 3;
@@ -412,6 +412,12 @@ public sealed class SqliteStorage
SpalteErgaenzen(connection, "Tasks", "OriginRunId", "TEXT NOT NULL DEFAULT ''");
SpalteErgaenzen(connection, "Tasks", "OriginTaskId", "TEXT NOT NULL DEFAULT ''");
// #717: Fill work — a deadline instead of an appointment, the author's estimate, and
// when a human was asked because no room was found.
SpalteErgaenzen(connection, "Tasks", "FillBy", "TEXT NULL");
SpalteErgaenzen(connection, "Tasks", "EstimateUsd", "TEXT NULL");
SpalteErgaenzen(connection, "Tasks", "FillAlertedAt", "TEXT NULL");
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
using var index = connection.CreateCommand();
index.CommandText = """
@@ -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.
@@ -363,6 +363,12 @@
"tasks.origin": "Herkunft",
"tasks.originTask": "bei der Arbeit an {id}",
"tasks.originRun": "Lauf öffnen",
"tasks.fillWork": "Füllarbeit: läuft, wenn das Budget Platz hat, spätestens bis {time}",
"tasks.fillAlerted": "bisher kein Platz gefunden, Mensch gefragt am {time}",
"tasks.estimate": "Schätzung des Auftraggebers: {usd} je Lauf (nicht gemessen)",
"tasks.fillBy": "Füllarbeit, spätestens bis",
"tasks.fillByHint": "Leer lassen für reguläre Arbeit. Füllarbeit läuft auf Budget, das der Tag übrig lässt; ein Datum meint das Ende dieses Tages.",
"tasks.estimateUsd": "Schätzung je Lauf (USD)",
"services.title": "Dienste",
"services.newService": "Neuer Dienst",
@@ -363,6 +363,12 @@
"tasks.origin": "Arose from",
"tasks.originTask": "while working on {id}",
"tasks.originRun": "open run",
"tasks.fillWork": "Fill work: runs when the budget has room, at the latest by {time}",
"tasks.fillAlerted": "no room found so far, a human was asked on {time}",
"tasks.estimate": "Author's estimate: {usd} per run (not measured)",
"tasks.fillBy": "Fill work, at the latest by",
"tasks.fillByHint": "Leave empty for regular work. Fill work runs on budget the day leaves unused; a date means the end of that day.",
"tasks.estimateUsd": "Estimate per run (USD)",
"services.title": "Services",
"services.newService": "New Service",
@@ -174,6 +174,14 @@ function brettAuswahl(tasks) {
erstelleElement("span", { text: t("tasks.board") }), auswahl);
}
/**
* #717: The date for the input field. A date is stored as the end of that day (the next
* midnight, UTC), so one millisecond earlier is the day that was meant.
*/
function fuellDatum(iso) {
return new Date(new Date(iso).getTime() - 1).toISOString().slice(0, 10);
}
function terminText(task) {
if (!task.when) return t("tasks.scheduleNone");
const art = task.when.kind;
@@ -233,6 +241,19 @@ function erstelleAufgabenKarte(task) {
zeilen.push(erstelleElement("p", { klasse: "text-klein", text: `⚠ ${task.hinweis}` }));
}
// #717: Fill work waits for room, not for a time — said on the card, or it looks forgotten.
if (task.fillBy) {
const teile = [t("tasks.fillWork", { time: formatiereDatumZeit(task.fillBy) })];
if (task.fillAlertedAt) teile.push(t("tasks.fillAlerted", { time: formatiereDatumZeit(task.fillAlertedAt) }));
zeilen.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: `◔ ${teile.join(" · ")}` }));
}
if (task.estimateUsd != null) {
zeilen.push(erstelleElement("p", {
klasse: "gedaempft text-klein",
text: t("tasks.estimate", { usd: formatiereKosten(task.estimateUsd) })
}));
}
// #716: What the budget refused, and what happens next.
const ausfall = task.budgetMiss;
if (ausfall) {
@@ -360,6 +381,17 @@ async function oeffneAufgabenDialog(task) {
// #714: The task this one serves — the run is told the chain up to the unit's goal.
const eltern = erstelleElement("input", { type: "text", value: task?.parentId || "", placeholder: "t-…" });
// #717: Fill work — a deadline instead of an appointment — and the author's estimate.
const fuellBis = erstelleElement("input", {
type: "date",
value: task?.fillBy ? fuellDatum(task.fillBy) : "",
title: t("tasks.fillByHint")
});
const schaetzung = erstelleElement("input", {
type: "number", min: "0", step: "0.01",
value: task?.estimateUsd != null ? String(task.estimateUsd) : ""
});
const hinweis = erstelleElement("p", { klasse: "gedaempft text-klein" });
const aktualisiere = () => {
@@ -371,6 +403,9 @@ async function oeffneAufgabenDialog(task) {
const ohneTermin = terminArt.value === "";
terminWert.disabled = ohneTermin;
zeitzone.disabled = ohneTermin;
// Fill work is one-time; a recurring schedule and a deadline contradict each other.
fuellBis.disabled = wiederkehrend;
if (wiederkehrend) fuellBis.value = "";
terminWert.placeholder =
{ cron: "e.g. 0 7-22/3 * * *", every: "e.g. 30m, 2h, 1d", at: "e.g. 2026-09-15T08:00" }[terminArt.value] || "";
};
@@ -387,6 +422,8 @@ async function oeffneAufgabenDialog(task) {
erstelleElement("label", { text: "Timezone:" }), zeitzone,
erstelleElement("label", { text: "Acceptance Criteria:" }), abnahme,
erstelleElement("label", { text: t("tasks.parent") + ":" }), eltern,
erstelleElement("label", { text: t("tasks.fillBy") + ":", title: t("tasks.fillByHint") }), fuellBis,
erstelleElement("label", { text: t("tasks.estimateUsd") + ":" }), schaetzung,
erstelleElement("span"), hinweis
);
@@ -418,7 +455,9 @@ async function oeffneAufgabenDialog(task) {
timeZone: terminArt.value ? zeitzone.value.trim() : null,
acceptance: abnahme.value.trim(),
priority: task?.priority ?? 3,
parentId: eltern.value.trim()
parentId: eltern.value.trim(),
fillBy: fuellBis.value || null,
estimateUsd: schaetzung.value === "" ? null : Number(schaetzung.value)
};
try {
@@ -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;
+8 -2
View File
@@ -129,7 +129,11 @@ public sealed record TaskDto(
string? BoardPath = null,
// #697: where the task came from — stamped by the engine.
string? OriginRunId = null,
string? OriginTaskId = null);
string? OriginTaskId = null,
// #717: fill work — a deadline instead of an appointment — and the author's estimate per run.
DateTime? FillBy = null,
decimal? EstimateUsd = null,
DateTime? FillAlertedAt = null);
/// <summary>
/// #716: Runs of this task the budget refused — only present while something is open
@@ -198,7 +202,9 @@ public sealed record TaskInputDto(
string? TimeZone = null,
string Acceptance = "",
int Priority = 3,
string? ParentId = null);
string? ParentId = null,
string? FillBy = null,
decimal? EstimateUsd = null);
/// <summary>Die Kostenprognose einer Instanz (#595).</summary>
public sealed record ForecastDto(
+7 -2
View File
@@ -113,7 +113,9 @@ internal static class Mapping
input.TimeZone,
input.Acceptance ?? "",
input.Priority,
input.ParentId ?? "");
input.ParentId ?? "",
input.FillBy,
input.EstimateUsd);
public static ForecastDto ToDto(this InstanzPrognose prognose)
=> new(
@@ -162,7 +164,10 @@ internal static class Mapping
CreatedBy: string.IsNullOrWhiteSpace(task.CreatedBy) ? null : task.CreatedBy,
ParentId: string.IsNullOrWhiteSpace(task.ParentId) ? null : task.ParentId,
OriginRunId: string.IsNullOrWhiteSpace(task.OriginRunId) ? null : task.OriginRunId,
OriginTaskId: string.IsNullOrWhiteSpace(task.OriginTaskId) ? null : task.OriginTaskId);
OriginTaskId: string.IsNullOrWhiteSpace(task.OriginTaskId) ? null : task.OriginTaskId,
FillBy: task.IsFillWork ? task.FillBy : null,
EstimateUsd: task.EstimateUsd,
FillAlertedAt: task.FillAlertedAt);
/// <summary>#684: The board of a task, for a list the human sees whole.</summary>
public static TaskDto WithBoard(this TaskDto dto, TaskItem task, Core.Organisation.OrgChart org)
@@ -0,0 +1,383 @@
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Budget;
/// <summary>
/// #717: Fill work — large, not urgent — runs on budget the day would otherwise leave unused,
/// and never overruns its deadline silently. Whether there is room is decided by the code.
/// </summary>
public sealed class FillWorkTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
private readonly SqliteTaskRepository _repo;
private readonly InMemoryAuditRepository _audit = new();
// 2027-03-10 14:00 UTC; the clocks' local zone is UTC.
private static readonly DateTime Afternoon = new(2027, 3, 10, 14, 0, 0, DateTimeKind.Utc);
private static readonly DateTime Evening = new(2027, 3, 10, 20, 0, 0, DateTimeKind.Utc);
public FillWorkTests()
{
_repo = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private static AgentConfig Agent(decimal dailyCost = 1.00m)
=> new() { AgentId = "worker", Budget = new AgentBudget { DailyCostUsd = dailyCost } };
/// <summary>The agent's runs average 0.05 USD over 1,000 tokens.</summary>
private static BudgetGuard Guard(decimal used = 0m)
=> new(new FakeUsage(used, [.. Enumerable.Range(0, 5).Select(_ => new RunUsage
{
AgentId = "worker", PromptTokens = 1_000, CostUsd = 0.05m, CostIsKnown = true
})]));
private static TaskItem Fill(string id, DateTime deadline, decimal? estimate = null, string parent = "") => new()
{
Id = id, Title = id, Assignee = "@worker", FillBy = deadline, EstimateUsd = estimate, ParentId = parent,
CreatedAt = Afternoon.AddDays(-1)
};
private void Receipt(string taskId, decimal cost, int tokens = 2_000)
=> _audit.Receipts.Enqueue(new RunReceipt
{
RunId = Guid.NewGuid().ToString("N"), AgentId = "worker", TaskId = taskId,
PromptTokens = tokens, CostUsd = cost, OccurredAt = Afternoon
});
// ═══════════════════ estimating, honestly ═══════════════════
[Fact]
public async Task Measured_runs_of_the_task_beat_everything_else()
{
var task = await _repo.UpsertAsync(Fill("t-1", Evening.AddDays(3), estimate: 5m), default);
Receipt("t-1", 0.40m);
Receipt("t-1", 0.60m);
var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(task, default);
estimate.Source.ShouldBe(EstimateSource.Measured);
estimate.Run.CostUsd.ShouldBe(0.50m);
estimate.Samples.ShouldBe(2);
estimate.IsGuess.ShouldBeFalse();
}
[Fact]
public async Task A_refusal_without_usage_is_not_a_measurement()
{
var task = await _repo.UpsertAsync(Fill("t-1", Evening.AddDays(3), estimate: 2m), default);
Receipt("t-1", 0m, tokens: 0);
var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(task, default);
estimate.Source.ShouldBe(EstimateSource.Stated, "a budget refusal costs nothing and says nothing");
}
[Fact]
public async Task After_the_first_part_ran_its_siblings_are_estimated_from_it()
{
await _repo.UpsertAsync(new TaskItem { Id = "t-big", Title = "big", CreatedAt = Afternoon }, default);
await _repo.UpsertAsync(Fill("t-part-1", Evening.AddDays(3), estimate: 3m, parent: "t-big"), default);
var second = await _repo.UpsertAsync(Fill("t-part-2", Evening.AddDays(3), estimate: 3m, parent: "t-big"), default);
Receipt("t-part-1", 0.80m);
var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(second, default);
estimate.Source.ShouldBe(EstimateSource.Siblings);
estimate.Run.CostUsd.ShouldBe(0.80m, "the measured part replaces the author's guess of 3 USD");
}
[Fact]
public async Task A_stated_estimate_is_a_guess_and_gets_tokens_at_the_agents_price()
{
var task = await _repo.UpsertAsync(Fill("t-1", Evening.AddDays(3), estimate: 0.50m), default);
var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(task, default);
estimate.Source.ShouldBe(EstimateSource.Stated);
estimate.IsGuess.ShouldBeTrue();
estimate.Run.Tokens.ShouldBe(10_000, "0.50 USD at 0.05 USD per 1,000 tokens");
FillWorkNotice.Describe(estimate).ShouldContain("estimated by its author, not measured");
}
[Fact]
public async Task Without_anything_the_agents_average_is_used_and_called_a_guess()
{
var task = await _repo.UpsertAsync(Fill("t-1", Evening.AddDays(3)), default);
var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(task, default);
estimate.Source.ShouldBe(EstimateSource.AgentAverage);
estimate.Run.CostUsd.ShouldBe(0.05m);
estimate.IsGuess.ShouldBeTrue();
}
// ═══════════════════ is there room? ═══════════════════
private BudgetHeadroom Headroom(DateTime now, decimal used, AgentConfig? agent = null, FillWorkSettings? settings = null)
{
var guard = Guard(used);
return new BudgetHeadroom(guard, () => [agent ?? Agent()], () => InstanceBudget.Unlimited, new UtcClock(now),
new TaskCostEstimator(_audit, _repo, guard), () => settings ?? new FillWorkSettings());
}
[Fact]
public async Task In_the_afternoon_fill_work_waits_for_the_evening()
{
var fill = Fill("t-fill", Evening.AddDays(3), estimate: 0.20m);
(await Headroom(Afternoon, used: 0m).CheckAsync(fill, [fill], Afternoon, default))
.ShouldBe(FillVerdict.NotYet, "at 14:00 'there is room' is a guess");
}
[Fact]
public async Task In_the_evening_it_runs_when_its_estimate_fits()
{
// Limit 1.00, used 0.50, margin 0.10: 0.40 left for an estimate of 0.30.
var fill = Fill("t-fill", Evening.AddDays(3), estimate: 0.30m);
(await Headroom(Evening, used: 0.50m).CheckAsync(fill, [fill], Evening, default)).ShouldBe(FillVerdict.Run);
}
[Fact]
public async Task It_is_measured_with_its_own_estimate_not_the_agents_average()
{
// 0.40 left — enough for the agent's average run of 0.05, not for this task's 0.60.
var fill = Fill("t-fill", Evening.AddDays(3), estimate: 0.60m);
(await Headroom(Evening, used: 0.50m).CheckAsync(fill, [fill], Evening, default)).ShouldBe(FillVerdict.NoRoom);
}
[Fact]
public async Task The_rest_of_the_days_schedule_comes_first()
{
// Four hourly runs à 0.05 until midnight (20:00 … 23:00) = 0.20: 1.00 - 0.50 - 0.20 - 0.10 = 0.20 < 0.30.
var hourly = new TaskItem
{
Id = "t-hourly", Title = "hourly", Assignee = "@worker", CreatedAt = Afternoon.AddDays(-1),
LastOccurrence = "2027-03-10T19:00:00.0000000Z",
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 * * * *", TimeZone = "UTC" }
};
var fill = Fill("t-fill", Evening.AddDays(3), estimate: 0.30m);
(await Headroom(Evening, used: 0.50m).CheckAsync(fill, [hourly, fill], Evening, default)).ShouldBe(FillVerdict.NoRoom);
}
[Fact]
public async Task Close_to_its_deadline_it_no_longer_waits_for_the_evening()
{
var fill = Fill("t-fill", Afternoon.AddHours(10), estimate: 0.20m);
(await Headroom(Afternoon, used: 0m).CheckAsync(fill, [fill], Afternoon, default)).ShouldBe(FillVerdict.Run);
}
[Fact]
public async Task Without_a_limit_there_is_nothing_to_wait_for()
{
var fill = Fill("t-fill", Evening.AddDays(3), estimate: 50m);
(await Headroom(Afternoon, used: 0m, agent: Agent(dailyCost: 0m)).CheckAsync(fill, [fill], Afternoon, default))
.ShouldBe(FillVerdict.Run);
}
// ═══════════════════ the scanner ═══════════════════
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(Evening, TimeSpan.Zero));
private TaskScanner Scanner(RecordingDispatcher dispatcher, IFillGate gate, List<TaskItem>? asked = null, int maxParallel = 4)
=> new(_repo, dispatcher, NullLoggerFactory.Instance, clock: _clock, maxParallelRuns: maxParallel,
fillGate: gate,
askAboutFill: (task, _) => { asked?.Add(task); return Task.CompletedTask; },
fillWarnBefore: () => TimeSpan.FromHours(24));
[Fact]
public async Task Fill_work_starts_only_when_the_gate_sees_room()
{
await _repo.UpsertAsync(Fill("t-fill", Evening.AddDays(3)), default);
var dispatcher = new RecordingDispatcher();
var gate = new FixedGate(FillVerdict.NoRoom);
(await Scanner(dispatcher, gate).ScanOnceAsync(default)).ShouldBe(0);
gate.Verdict = FillVerdict.Run;
(await Scanner(dispatcher, gate).ScanOnceAsync(default)).ShouldBe(1);
dispatcher.Started.ShouldBe(["t-fill"]);
}
[Fact]
public async Task Fill_work_waits_while_other_work_of_its_agent_waits()
{
// A carried-over task of the same agent waits for headroom. The fill task would fit —
// but late work was due and fill work never was, so it does not overtake it.
await _repo.UpsertAsync(new TaskItem { Id = "t-carried", Title = "carried", Assignee = "@worker" }, default);
await _repo.TryClaimAsync("t-carried", "occ-1", "token", Evening, Evening.AddMinutes(-15), default);
await _repo.CarryOverAsync("t-carried", "token", null, Evening.AddMinutes(-1), Evening, default);
await _repo.UpsertAsync(Fill("t-fill", Evening.AddDays(3)), default);
await _repo.UpsertAsync(Fill("t-fill-other", Evening.AddDays(3)) with { Assignee = "@other" }, default);
var dispatcher = new RecordingDispatcher();
var scanner = new TaskScanner(_repo, dispatcher, NullLoggerFactory.Instance, clock: _clock,
lateRunGate: new NoRoomForLateWork(), fillGate: new FixedGate(FillVerdict.Run));
await scanner.ScanOnceAsync(default);
dispatcher.Started.ShouldBe(["t-fill-other"], "another agent's fill work is not held back");
}
[Fact]
public async Task Regular_work_goes_first_and_the_nearest_deadline_leads_the_fill_work()
{
await _repo.UpsertAsync(Fill("t-fill-late", Evening.AddDays(5)), default);
await _repo.UpsertAsync(Fill("t-fill-soon", Evening.AddDays(2)), default);
await _repo.UpsertAsync(new TaskItem { Id = "t-regular", Title = "regular", Assignee = "@worker", Priority = 1 }, default);
var dispatcher = new RecordingDispatcher();
await Scanner(dispatcher, new FixedGate(FillVerdict.Run)).ScanOnceAsync(default);
dispatcher.Started.ShouldBe(["t-regular", "t-fill-soon", "t-fill-late"]);
}
[Fact]
public async Task A_human_is_asked_once_when_the_deadline_comes_close()
{
await _repo.UpsertAsync(Fill("t-fill", Evening.AddHours(20)), default);
var asked = new List<TaskItem>();
var scanner = Scanner(new RecordingDispatcher(), new FixedGate(FillVerdict.NoRoom), asked);
await scanner.ScanOnceAsync(default);
await scanner.ScanOnceAsync(default);
asked.Select(t => t.Id).ShouldBe(["t-fill"], "asked once, not every minute");
(await _repo.GetAsync("t-fill", default))!.FillAlertedAt.ShouldNotBeNull();
}
[Fact]
public async Task Far_from_the_deadline_nobody_is_bothered()
{
await _repo.UpsertAsync(Fill("t-fill", Evening.AddDays(3)), default);
var asked = new List<TaskItem>();
await Scanner(new RecordingDispatcher(), new FixedGate(FillVerdict.NoRoom), asked).ScanOnceAsync(default);
asked.ShouldBeEmpty();
}
[Fact]
public async Task Past_its_deadline_fill_work_runs_as_regular_work()
{
await _repo.UpsertAsync(Fill("t-fill", Evening.AddHours(2)), default);
_clock.SetUtcNow(new DateTimeOffset(Evening.AddHours(3), TimeSpan.Zero));
var dispatcher = new RecordingDispatcher();
(await Scanner(dispatcher, new FixedGate(FillVerdict.NoRoom)).ScanOnceAsync(default)).ShouldBe(1);
dispatcher.Started.ShouldBe(["t-fill"], "the deadline was the promise");
}
[Fact]
public void The_question_to_the_human_hangs_below_the_fill_work()
{
var question = FillWorkNotice.QuestionFor(Fill("t-fill", Evening.AddDays(1)),
new TaskEstimate(new RunEstimate(0.80m, 0), EstimateSource.Measured, 3));
question.Assignee.ShouldBe(TaskAssignee.Human);
question.ParentId.ShouldBe("t-fill");
question.Body.ShouldContain("0.80 USD (measured over 3 run(s))");
question.Body.ShouldContain("raise the agent's daily budget");
}
// ═══════════════════ the definition ═══════════════════
[Fact]
public void Deadline_and_estimate_survive_the_file()
{
var task = Fill("t-fill", new DateTime(2027, 3, 12, 18, 0, 0, DateTimeKind.Utc), estimate: 1.25m);
TaskFrontmatter.TryParse(TaskFrontmatter.Serialize(task), out var read, out _).ShouldBeTrue();
read.FillBy.ShouldBe(task.FillBy);
read.EstimateUsd.ShouldBe(1.25m);
read.IsFillWork.ShouldBeTrue();
}
[Fact]
public void A_bare_date_means_the_end_of_that_day()
=> TaskFrontmatter.ParseUtc("2027-03-12").ShouldBe(new DateTime(2027, 3, 13, 0, 0, 0, DateTimeKind.Utc));
[Fact]
public async Task Deadline_and_estimate_survive_the_database()
{
await _repo.UpsertAsync(Fill("t-fill", new DateTime(2027, 3, 12, 18, 0, 0, DateTimeKind.Utc), estimate: 0.75m), default);
var read = (await _repo.GetAsync("t-fill", default))!;
read.FillBy.ShouldBe(new DateTime(2027, 3, 12, 18, 0, 0, DateTimeKind.Utc));
read.EstimateUsd.ShouldBe(0.75m);
}
[Fact]
public void A_recurring_task_is_never_fill_work()
=> (Fill("t", Evening) with { When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "1h", TimeZone = "UTC" } })
.IsFillWork.ShouldBeFalse();
// ═══════════════════ test doubles ═══════════════════
private sealed class FixedGate(FillVerdict verdict) : IFillGate
{
public FillVerdict Verdict { get; set; } = verdict;
public Task<FillVerdict> CheckAsync(TaskItem fillTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
=> Task.FromResult(Verdict);
}
private sealed class NoRoomForLateWork : ILateRunGate
{
public Task<bool> HasRoomAsync(TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
=> Task.FromResult(false);
}
private sealed class RecordingDispatcher : ITaskDispatcher
{
public List<string> Started { get; } = [];
public Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
{
lock (Started)
Started.Add(task.Id);
return Task.FromResult(TaskLauf.Erledigt);
}
}
private sealed class UtcClock(DateTime now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(now, TimeSpan.Zero);
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
private sealed class FakeUsage(decimal usedCost, IReadOnlyList<RunUsage> recent) : IUsageRepository
{
public Task RecordAsync(RunUsage usage, CancellationToken ct) => Task.CompletedTask;
public Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
=> Task.FromResult(new DailyUsage(date, 0, 0, usedCost, true, 0));
public Task<IReadOnlyList<RunUsage>> GetRecentAsync(int limit, CancellationToken ct) => Task.FromResult(recent);
public Task<IReadOnlyList<RunUsage>> GetRecentForAgentAsync(string agentId, int limit, CancellationToken ct)
=> Task.FromResult(recent);
public Task<int> PurgeBeforeAsync(DateOnly date, CancellationToken ct) => Task.FromResult(0);
}
}
@@ -0,0 +1,86 @@
using System.Text.Json;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Tools.Taskboard;
using ClawdDotNet.Tools.Tests.FileRW;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.Taskboard;
/// <summary>#717: An agent hands out fill work — a deadline instead of an appointment, and its estimate.</summary>
public sealed class FillWorkToolTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
private readonly SqliteTaskRepository _repo;
private readonly TaskboardTool _tool = new();
public FillWorkToolTests()
{
_repo = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private Task<ToolResult> Run(object input)
=> _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), new AgentToolContext(
"lead", "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance,
CancellationToken.None, SharedWorkspacePath: Path.Combine(_directory, "SharedWorkspace"), Tasks: _repo),
CancellationToken.None);
private static string IdOf(ToolResult result) => result.Content.Split(' ', 4)[2];
private static string InAWeek => DateTime.UtcNow.AddDays(7).ToString("yyyy-MM-dd");
[Fact]
public async Task Fill_work_is_created_with_its_deadline_and_estimate()
{
var created = await Run(new { action = "task_create", title = "Rewrite all 400 listings", assignee = "@ads", fill_by = InAWeek, estimate_usd = 1.5 });
created.Success.ShouldBeTrue(created.ErrorMessage);
var task = (await _repo.GetAsync(IdOf(created), default))!;
task.IsFillWork.ShouldBeTrue();
task.EstimateUsd.ShouldBe(1.5m);
var list = (await Run(new { action = "task_list" })).Content;
list.ShouldContain("Fill work: runs when the budget has room");
list.ShouldContain("Author's estimate: 1.50 USD per run (not measured)");
}
[Fact]
public async Task A_recurring_task_cannot_be_fill_work()
{
var result = await Run(new
{
action = "task_create", title = "Hourly check", fill_by = InAWeek,
when = new { kind = "every", value = "1h", tz = "UTC" }
});
result.Success.ShouldBeFalse();
result.ErrorMessage!.ShouldContain("one-time");
}
[Fact]
public async Task A_deadline_in_the_past_is_refused()
{
var result = await Run(new { action = "task_create", title = "Too late", fill_by = "2020-01-01" });
result.ErrorMessage!.ShouldContain("lies in the past");
}
[Fact]
public async Task An_empty_deadline_turns_fill_work_back_into_regular_work()
{
var id = IdOf(await Run(new { action = "task_create", title = "Rewrite all 400 listings", assignee = "@lead", fill_by = InAWeek }));
(await Run(new { action = "task_update", id, fill_by = "" })).Success.ShouldBeTrue();
(await _repo.GetAsync(id, default))!.IsFillWork.ShouldBeFalse();
}
}