diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs
index 8048fec..b1e7da7 100644
--- a/src/ClawdDotNet.App/AppHost.cs
+++ b/src/ClawdDotNet.App/AppHost.cs
@@ -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 () =>
diff --git a/src/ClawdDotNet.App/Services/TaskAdministration.cs b/src/ClawdDotNet.App/Services/TaskAdministration.cs
index 6946b4e..2776fc7 100644
--- a/src/ClawdDotNet.App/Services/TaskAdministration.cs
+++ b/src/ClawdDotNet.App/Services/TaskAdministration.cs
@@ -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);
///
/// 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));
}
+ ///
+ /// #717: Fill work is one-time, and its deadline lies ahead — a recurring schedule and a
+ /// deadline contradict each other.
+ ///
+ 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;
+ }
+
/// #714: The parent must exist and must not lie below the task itself.
private async Task CheckParentAsync(string? taskId, string parentId, CancellationToken ct)
{
diff --git a/src/ClawdDotNet.Core/Budget/BudgetHeadroom.cs b/src/ClawdDotNet.Core/Budget/BudgetHeadroom.cs
index 18b9b8a..5ffce00 100644
--- a/src/ClawdDotNet.Core/Budget/BudgetHeadroom.cs
+++ b/src/ClawdDotNet.Core/Budget/BudgetHeadroom.cs
@@ -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.
///
-public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
+public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook, IFillGate
{
/// Share of each limit kept free as a reserve for surprises.
public const decimal SafetyShare = 0.10m;
@@ -31,20 +31,55 @@ public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
private readonly Func> _agents;
private readonly Func _instanceBudget;
private readonly TimeProvider _clock;
+ private readonly TaskCostEstimator? _estimator;
+ private readonly Func _fillSettings;
public BudgetHeadroom(
BudgetGuard guard,
Func> agents,
Func instanceBudget,
- TimeProvider? clock = null)
+ TimeProvider? clock = null,
+ TaskCostEstimator? estimator = null,
+ Func? fillSettings = null)
{
_guard = guard;
_agents = agents;
_instanceBudget = instanceBudget;
_clock = clock ?? TimeProvider.System;
+ _estimator = estimator;
+ _fillSettings = fillSettings ?? (() => new FillWorkSettings());
}
- public async Task HasRoomAsync(
+ public Task HasRoomAsync(
+ TaskItem lateTask, IReadOnlyList runnable, DateTime nowUtc, CancellationToken ct)
+ => HasRoomCoreAsync(lateTask, runnable, nowUtc, ct);
+
+ ///
+ /// #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.
+ ///
+ public async Task CheckAsync(
+ TaskItem fillTask, IReadOnlyList 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 HasRoomCoreAsync(
TaskItem lateTask, IReadOnlyList 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
///
/// #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.
///
public async Task> ShortfallsAsync(
IReadOnlyList 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)
diff --git a/src/ClawdDotNet.Core/Budget/FillWorkNotice.cs b/src/ClawdDotNet.Core/Budget/FillWorkNotice.cs
new file mode 100644
index 0000000..5930e0b
--- /dev/null
+++ b/src/ClawdDotNet.Core/Budget/FillWorkNotice.cs
@@ -0,0 +1,53 @@
+using System.Globalization;
+using System.Text;
+using ClawdDotNet.Core.Tasks;
+
+namespace ClawdDotNet.Core.Budget;
+
+///
+/// #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.
+///
+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
+ };
+ }
+
+ /// "0.80 USD (measured over 3 runs)" — and a guess says it is one.
+ 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"
+ };
+ }
+}
diff --git a/src/ClawdDotNet.Core/Budget/FillWorkSettings.cs b/src/ClawdDotNet.Core/Budget/FillWorkSettings.cs
new file mode 100644
index 0000000..61bd9c0
--- /dev/null
+++ b/src/ClawdDotNet.Core/Budget/FillWorkSettings.cs
@@ -0,0 +1,36 @@
+using System.Text.Json.Serialization;
+
+namespace ClawdDotNet.Core.Budget;
+
+///
+/// #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.
+///
+public sealed class FillWorkSettings
+{
+ ///
+ /// 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.
+ ///
+ [JsonPropertyName("fromHour")]
+ public int FromHour { get; set; } = 18;
+
+ ///
+ /// 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.
+ ///
+ [JsonPropertyName("warnHoursBeforeDeadline")]
+ public int WarnHoursBeforeDeadline { get; set; } = 24;
+
+ ///
+ /// 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.
+ ///
+ [JsonPropertyName("largeTaskShare")]
+ public decimal LargeTaskShare { get; set; } = 0.5m;
+
+ [JsonIgnore]
+ public TimeSpan WarnBeforeDeadline => TimeSpan.FromHours(Math.Max(0, WarnHoursBeforeDeadline));
+}
diff --git a/src/ClawdDotNet.Core/Budget/TaskCostEstimator.cs b/src/ClawdDotNet.Core/Budget/TaskCostEstimator.cs
new file mode 100644
index 0000000..7b63e7f
--- /dev/null
+++ b/src/ClawdDotNet.Core/Budget/TaskCostEstimator.cs
@@ -0,0 +1,116 @@
+using ClawdDotNet.Core.Audit;
+using ClawdDotNet.Core.Tasks;
+
+namespace ClawdDotNet.Core.Budget;
+
+/// #717: Where an estimate came from — from the most to the least reliable.
+public enum EstimateSource
+{
+ /// The task's own runs, measured.
+ Measured,
+
+ /// Runs of its siblings — the other parts of the same split task.
+ Siblings,
+
+ /// What the task's author wrote down. A guess, and shown as one.
+ Stated,
+
+ /// The average run of the agent — says nothing about this task in particular.
+ AgentAverage,
+
+ /// Nothing to go on.
+ None
+}
+
+/// #717: What one run of a task is expected to cost, and how much that is worth.
+public sealed record TaskEstimate(RunEstimate Run, EstimateSource Source, int Samples)
+{
+ /// Stated or averaged over everything the agent does — not measured on this work.
+ public bool IsGuess => Source is EstimateSource.Stated or EstimateSource.AgentAverage or EstimateSource.None;
+
+ public static TaskEstimate Nothing { get; } = new(default, EstimateSource.None, 0);
+}
+
+///
+/// #717: Estimates a run of one particular task — honestly.
+///
+/// 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:
+///
+/// - the task's own runs (a fill task that already ran part of the way, or ran before),
+/// - its siblings — the parts of the same split task, measured as soon as the first one ran,
+/// - the author's estimate (), marked as a guess,
+/// - the agent's average run, also a guess.
+///
+/// After the first run the measured value replaces the guess — nothing has to be
+/// updated for that, the next estimate simply finds the run.
+///
+public sealed class TaskCostEstimator
+{
+ /// How many runs are averaged — recent enough to follow a change, enough to smooth one outlier.
+ 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 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();
+ 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);
+ }
+
+ /// Runs that used something — a refusal or an abort before the first call says nothing about cost.
+ private static List Measured(IReadOnlyList receipts)
+ => receipts.Where(r => r.PromptTokens + r.CompletionTokens > 0)
+ .OrderByDescending(r => r.OccurredAt)
+ .Take(Sample)
+ .ToList();
+
+ private static TaskEstimate Average(IReadOnlyList 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);
+}
diff --git a/src/ClawdDotNet.Core/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs
index b32a1c6..373a381 100644
--- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs
+++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs
@@ -147,6 +147,10 @@ public sealed class InstanceConfig
[JsonPropertyName("cases")]
public Cases.CaseSettings Cases { get; set; } = new();
+ /// #717: When unused budget is filled, and when a task counts as large.
+ [JsonPropertyName("fillWork")]
+ public Budget.FillWorkSettings FillWork { get; set; } = new();
+
[JsonPropertyName("maxDelegationDepth")]
public int MaxDelegationDepth { get; set; } = 3;
diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
index a1e7199..9ebb9fa 100644
--- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
+++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
@@ -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 = """
diff --git a/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs b/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs
index be99078..8d8f388 100644
--- a/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs
+++ b/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs
@@ -94,6 +94,12 @@ public interface ITaskRepository
///
Task ClearBudgetMissAsync(string id, DateTime now, CancellationToken ct);
+ ///
+ /// #717: Records that a human was asked about fill work that found no room before its
+ /// deadline. Returns false if that had already happened — asked once.
+ ///
+ Task MarkFillAlertedAsync(string id, DateTime now, CancellationToken ct);
+
///
/// Reconciliation beim Start: verwaiste Claims (älter als )
/// lösen und die betroffenen Aufgaben von zurück
@@ -108,5 +114,8 @@ public interface ITaskRepository
///
Task> ListBlockedByAsync(string blockerId, CancellationToken ct);
+ /// #717: The tasks directly below a task in the goal chain (#714), archived ones included.
+ Task> ListChildrenAsync(string parentId, CancellationToken ct);
+
Task CountAsync(CancellationToken ct);
}
diff --git a/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs b/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs
index b48a9d2..e3f8837 100644
--- a/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs
+++ b/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs
@@ -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 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 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> 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();
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ results.Add(Read(reader));
+
+ return results;
+ }
+
public async Task 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 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))
};
}
diff --git a/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs b/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs
index 2c30dfd..a302afe 100644
--- a/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs
+++ b/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs
@@ -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;
+ ///
+ /// #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.
+ ///
+ 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;
+ }
+
+ /// #717: An amount in USD; negative or unreadable is no estimate.
+ 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();
}
diff --git a/src/ClawdDotNet.Core/Tasks/TaskModels.cs b/src/ClawdDotNet.Core/Tasks/TaskModels.cs
index 9d6db33..df16cc0 100644
--- a/src/ClawdDotNet.Core/Tasks/TaskModels.cs
+++ b/src/ClawdDotNet.Core/Tasks/TaskModels.cs
@@ -252,6 +252,31 @@ public sealed record TaskItem
/// #697: The task that run was working on, if any — the task this one arose from.
public string OriginTaskId { get; init; } = "";
+ // ─── Fill work and estimates (#717) ───
+
+ ///
+ /// #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.
+ ///
+ public DateTime? FillBy { get; init; }
+
+ ///
+ /// #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 ).
+ ///
+ public decimal? EstimateUsd { get; init; }
+
+ ///
+ /// #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.
+ ///
+ public DateTime? FillAlertedAt { get; init; }
+
+ /// #717: One-time work with a deadline instead of an appointment.
+ public bool IsFillWork => FillBy is not null && !IsRecurring;
+
/// A one-time task that was refused for lack of budget and carried over.
public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0;
diff --git a/src/ClawdDotNet.Core/Tasks/TaskScanner.cs b/src/ClawdDotNet.Core/Tasks/TaskScanner.cs
index 58815ea..e9246c5 100644
--- a/src/ClawdDotNet.Core/Tasks/TaskScanner.cs
+++ b/src/ClawdDotNet.Core/Tasks/TaskScanner.cs
@@ -53,6 +53,29 @@ public interface ILateRunGate
Task HasRoomAsync(TaskItem lateTask, IReadOnlyList runnable, DateTime nowUtc, CancellationToken ct);
}
+/// #717: Whether fill work may start now.
+public enum FillVerdict
+{
+ /// There is room — it starts.
+ Run,
+
+ /// Too early in the day; the forecast is still a guess.
+ NotYet,
+
+ /// The rest of the day needs the budget.
+ NoRoom
+}
+
+///
+/// #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.
+///
+public interface IFillGate
+{
+ Task CheckAsync(TaskItem fillTask, IReadOnlyList runnable, DateTime nowUtc, CancellationToken ct);
+}
+
///
/// #716: The day ahead — where the budget will not reach the end of today's schedule.
///
@@ -139,6 +162,20 @@ public sealed class TaskScanner : IAsyncDisposable
private readonly IBudgetOutlook? _outlook;
private readonly ITaskTreeWatch? _treeWatch;
+ /// #717: Whether fill work may use today's unused budget.
+ private readonly IFillGate? _fillGate;
+
+ /// #717: Asks a human about fill work that found no room before its deadline.
+ private readonly Func? _askAboutFill;
+
+ private readonly Func _fillWarnBefore;
+
+ /// #717: Fill work already reported as past its deadline — once per task.
+ private readonly HashSet _warnedOverdueFill = [];
+
+ /// How many fill tasks waited for room at the last tick.
+ private int _lastWaitingFill;
+
/// How often the day ahead is looked at — a forecast, not a watchdog.
public static readonly TimeSpan OutlookInterval = TimeSpan.FromMinutes(30);
@@ -165,9 +202,15 @@ public sealed class TaskScanner : IAsyncDisposable
Func? budgetMissPolicy = null,
ILateRunGate? lateRunGate = null,
IBudgetOutlook? budgetOutlook = null,
- ITaskTreeWatch? treeWatch = null)
+ ITaskTreeWatch? treeWatch = null,
+ IFillGate? fillGate = null,
+ Func? askAboutFill = null,
+ Func? 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();
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(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;
}
+ ///
+ /// #717: Fill work that has not found room by deadline - warnBefore 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.
+ ///
+ 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);
+ }
+ }
+
///
/// 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");
+ /// The order in which due work is started: what is due, what is late, what fills.
+ private enum RunKind
+ {
+ Regular,
+ Late,
+ Fill
+ }
+
///
/// Auto-Dispatch: Wird eine Aufgabe fertig, werden die auf sie wartenden Aufgaben
/// freigegeben, sobald alle ihre Blocker erledigt sind.
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
index 84ef56f..4da0349 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
@@ -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",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
index 760f11e..149c409 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
@@ -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",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js
index feb1dd3..34bf775 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js
@@ -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 {
diff --git a/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs b/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs
index d75f1b0..da528d6 100644
--- a/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs
+++ b/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs
@@ -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 ───
+ ///
+ /// #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 has.
+ ///
+ 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;
diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs
index be841eb..b5c5a63 100644
--- a/src/ClawdDotNet.Web/Contracts/Dtos.cs
+++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs
@@ -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);
///
/// #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);
/// Die Kostenprognose einer Instanz (#595).
public sealed record ForecastDto(
diff --git a/src/ClawdDotNet.Web/Contracts/Mapping.cs b/src/ClawdDotNet.Web/Contracts/Mapping.cs
index 7f78c50..97c699b 100644
--- a/src/ClawdDotNet.Web/Contracts/Mapping.cs
+++ b/src/ClawdDotNet.Web/Contracts/Mapping.cs
@@ -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);
/// #684: The board of a task, for a list the human sees whole.
public static TaskDto WithBoard(this TaskDto dto, TaskItem task, Core.Organisation.OrgChart org)
diff --git a/tests/ClawdDotNet.Core.Tests/Budget/FillWorkTests.cs b/tests/ClawdDotNet.Core.Tests/Budget/FillWorkTests.cs
new file mode 100644
index 0000000..b567669
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Budget/FillWorkTests.cs
@@ -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;
+
+///
+/// #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.
+///
+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 } };
+
+ /// The agent's runs average 0.05 USD over 1,000 tokens.
+ 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? 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();
+ 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();
+
+ 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 CheckAsync(TaskItem fillTask, IReadOnlyList runnable, DateTime nowUtc, CancellationToken ct)
+ => Task.FromResult(Verdict);
+ }
+
+ private sealed class NoRoomForLateWork : ILateRunGate
+ {
+ public Task HasRoomAsync(TaskItem lateTask, IReadOnlyList runnable, DateTime nowUtc, CancellationToken ct)
+ => Task.FromResult(false);
+ }
+
+ private sealed class RecordingDispatcher : ITaskDispatcher
+ {
+ public List Started { get; } = [];
+
+ public Task 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 recent) : IUsageRepository
+ {
+ public Task RecordAsync(RunUsage usage, CancellationToken ct) => Task.CompletedTask;
+
+ public Task GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
+ => Task.FromResult(new DailyUsage(date, 0, 0, usedCost, true, 0));
+
+ public Task> GetRecentAsync(int limit, CancellationToken ct) => Task.FromResult(recent);
+
+ public Task> GetRecentForAgentAsync(string agentId, int limit, CancellationToken ct)
+ => Task.FromResult(recent);
+
+ public Task PurgeBeforeAsync(DateOnly date, CancellationToken ct) => Task.FromResult(0);
+ }
+}
diff --git a/tests/ClawdDotNet.Tools.Tests/Taskboard/FillWorkToolTests.cs b/tests/ClawdDotNet.Tools.Tests/Taskboard/FillWorkToolTests.cs
new file mode 100644
index 0000000..cedb02d
--- /dev/null
+++ b/tests/ClawdDotNet.Tools.Tests/Taskboard/FillWorkToolTests.cs
@@ -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;
+
+/// #717: An agent hands out fill work — a deadline instead of an appointment, and its estimate.
+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 Run(object input)
+ => _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), new AgentToolContext(
+ "lead", "inst", new Dictionary(), 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();
+ }
+}