From 183e922278e0903f61709508e87064ee0e78048b Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 25 Sep 2026 15:34:15 +0200 Subject: [PATCH] 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 --- src/ClawdDotNet.App/AppHost.cs | 19 +- .../Services/TaskAdministration.cs | 38 +- src/ClawdDotNet.Core/Budget/BudgetHeadroom.cs | 65 ++- src/ClawdDotNet.Core/Budget/FillWorkNotice.cs | 53 +++ .../Budget/FillWorkSettings.cs | 36 ++ .../Budget/TaskCostEstimator.cs | 116 ++++++ src/ClawdDotNet.Core/Config/InstanceConfig.cs | 4 + src/ClawdDotNet.Core/Storage/SqliteStorage.cs | 6 + src/ClawdDotNet.Core/Tasks/ITaskRepository.cs | 9 + .../Tasks/SqliteTaskRepository.cs | 44 +- src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs | 33 ++ src/ClawdDotNet.Core/Tasks/TaskModels.cs | 25 ++ src/ClawdDotNet.Core/Tasks/TaskScanner.cs | 148 ++++++- .../wwwroot/i18n/de.json | 6 + .../wwwroot/i18n/en.json | 6 + .../wwwroot/views/tasks.js | 41 +- .../TaskboardTool.cs | 84 +++- src/ClawdDotNet.Web/Contracts/Dtos.cs | 10 +- src/ClawdDotNet.Web/Contracts/Mapping.cs | 9 +- .../Budget/FillWorkTests.cs | 383 ++++++++++++++++++ .../Taskboard/FillWorkToolTests.cs | 86 ++++ 21 files changed, 1185 insertions(+), 36 deletions(-) create mode 100644 src/ClawdDotNet.Core/Budget/FillWorkNotice.cs create mode 100644 src/ClawdDotNet.Core/Budget/FillWorkSettings.cs create mode 100644 src/ClawdDotNet.Core/Budget/TaskCostEstimator.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Budget/FillWorkTests.cs create mode 100644 tests/ClawdDotNet.Tools.Tests/Taskboard/FillWorkToolTests.cs 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(); + } +}