Fuellarbeit: zeitlich flexible Arbeit fuellt ungenutztes Budget (#717, Stufe 1)
Eine einmalige Aufgabe mit fill_by (spaetestens bis) statt Termin laeuft, wenn der Tag Budget uebrig laesst: nach regulaerer und nachgeholter Arbeit, nur wenn nichts vom Agenten wartet, bevorzugt ab 18 Uhr, und nur wenn ihre eigene Schaetzung in den Spielraum aus #716 passt. Kurz vor der Frist wird einmal ein Mensch gefragt; nach der Frist laeuft sie als regulaere Arbeit. Ehrliche Schaetzung (TaskCostEstimator): eigene Laeufe, dann Geschwister unter derselben Elternaufgabe, dann die Angabe des Autors (als Schaetzung gekennzeichnet), zuletzt das Mittel des Agenten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
25f74d9ad6
commit
183e922278
@@ -22,7 +22,7 @@ namespace ClawdDotNet.Core.Budget;
|
||||
/// then only the margin keeps a reserve. That errs towards letting late work through;
|
||||
/// the budget check at the start of the run still refuses once the limit is reached.</para>
|
||||
/// </summary>
|
||||
public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
|
||||
public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook, IFillGate
|
||||
{
|
||||
/// <summary>Share of each limit kept free as a reserve for surprises.</summary>
|
||||
public const decimal SafetyShare = 0.10m;
|
||||
@@ -31,20 +31,55 @@ public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
|
||||
private readonly Func<IReadOnlyList<AgentConfig>> _agents;
|
||||
private readonly Func<InstanceBudget> _instanceBudget;
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly TaskCostEstimator? _estimator;
|
||||
private readonly Func<FillWorkSettings> _fillSettings;
|
||||
|
||||
public BudgetHeadroom(
|
||||
BudgetGuard guard,
|
||||
Func<IReadOnlyList<AgentConfig>> agents,
|
||||
Func<InstanceBudget> instanceBudget,
|
||||
TimeProvider? clock = null)
|
||||
TimeProvider? clock = null,
|
||||
TaskCostEstimator? estimator = null,
|
||||
Func<FillWorkSettings>? fillSettings = null)
|
||||
{
|
||||
_guard = guard;
|
||||
_agents = agents;
|
||||
_instanceBudget = instanceBudget;
|
||||
_clock = clock ?? TimeProvider.System;
|
||||
_estimator = estimator;
|
||||
_fillSettings = fillSettings ?? (() => new FillWorkSettings());
|
||||
}
|
||||
|
||||
public async Task<bool> HasRoomAsync(
|
||||
public Task<bool> HasRoomAsync(
|
||||
TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
|
||||
=> HasRoomCoreAsync(lateTask, runnable, nowUtc, ct);
|
||||
|
||||
/// <summary>
|
||||
/// #717: Fill work runs in the same headroom as late work — after it, since late work was
|
||||
/// due and fill work was not. Two things are added: it prefers the evening, when the
|
||||
/// forecast for the rest of the day is nearly a count, and it is measured with its own
|
||||
/// estimate, not the agent's average run — fill work is by definition the large kind.
|
||||
/// </summary>
|
||||
public async Task<FillVerdict> CheckAsync(
|
||||
TaskItem fillTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
|
||||
{
|
||||
var agents = _agents();
|
||||
var agent = FindAgent(agents, fillTask);
|
||||
|
||||
// Without a limit there is no unused budget to wait for — the work just runs.
|
||||
if (agent is null || (!agent.Budget.HasAnyLimit && !_instanceBudget().HasAnyLimit))
|
||||
return FillVerdict.Run;
|
||||
|
||||
var settings = _fillSettings();
|
||||
var nearDeadline = fillTask.FillBy is { } deadline && deadline - nowUtc <= settings.WarnBeforeDeadline;
|
||||
var localHour = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc), _clock.LocalTimeZone).Hour;
|
||||
if (!nearDeadline && localHour < Math.Clamp(settings.FromHour, 0, 24))
|
||||
return FillVerdict.NotYet;
|
||||
|
||||
return await HasRoomCoreAsync(fillTask, runnable, nowUtc, ct) ? FillVerdict.Run : FillVerdict.NoRoom;
|
||||
}
|
||||
|
||||
private async Task<bool> HasRoomCoreAsync(
|
||||
TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
|
||||
{
|
||||
var agents = _agents();
|
||||
@@ -66,17 +101,25 @@ public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
|
||||
return estimate;
|
||||
}
|
||||
|
||||
var ownEstimate = await EstimateFor(agent.AgentId);
|
||||
var agentEstimate = await EstimateFor(agent.AgentId);
|
||||
|
||||
// Regular work of the rest of the day: everything scheduled except late work
|
||||
// itself — that is what has to wait for headroom, not what headroom is kept for.
|
||||
var regular = runnable.Where(t => t.Id != lateTask.Id && !IsLate(t)).ToList();
|
||||
// #717: The task's own estimate where there is one — measured, from its siblings, or
|
||||
// stated — instead of the agent's average run.
|
||||
var ownEstimate = _estimator is null ? agentEstimate : (await _estimator.EstimateAsync(lateTask, ct)) switch
|
||||
{
|
||||
{ Source: EstimateSource.None } => agentEstimate,
|
||||
var estimate => estimate.Run
|
||||
};
|
||||
|
||||
// Regular work of the rest of the day: everything scheduled except late work and
|
||||
// fill work — that is what has to wait for headroom, not what headroom is kept for.
|
||||
var regular = runnable.Where(t => t.Id != lateTask.Id && !IsLate(t) && !t.IsFillWork).ToList();
|
||||
|
||||
if (agent.Budget.HasAnyLimit)
|
||||
{
|
||||
var demand = default(RunEstimate);
|
||||
foreach (var task in regular.Where(t => IsOf(t, agent.AgentId)))
|
||||
demand += Times(ownEstimate, TaskSchedule.OccurrencesBetween(task, nowUtc, endOfDay));
|
||||
demand += Times(agentEstimate, TaskSchedule.OccurrencesBetween(task, nowUtc, endOfDay));
|
||||
|
||||
var used = await _guard.GetUsedTodayAsync(agent.AgentId, ct);
|
||||
var reserved = _guard.Reserved(agent.AgentId);
|
||||
@@ -111,8 +154,8 @@ public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
|
||||
/// <summary>
|
||||
/// #716: Walks through the rest of today's schedule in time order and finds, per agent
|
||||
/// with a limit and for the instance, the first run the budget would no longer cover.
|
||||
/// Late work is left out: it only ever runs in headroom, so it cannot be what makes the
|
||||
/// day's own runs fail.
|
||||
/// Late work and fill work (#717) are left out: they only ever run in headroom, so they
|
||||
/// cannot be what makes the day's own runs fail.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BudgetShortfall>> ShortfallsAsync(
|
||||
IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
|
||||
@@ -124,7 +167,7 @@ public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
|
||||
|
||||
// Every planned run until midnight, with whose it is.
|
||||
var planned = new List<(DateTime At, string AgentId)>();
|
||||
foreach (var task in runnable.Where(t => !IsLate(t)))
|
||||
foreach (var task in runnable.Where(t => !IsLate(t) && !t.IsFillWork))
|
||||
{
|
||||
var agent = FindAgent(agents, task);
|
||||
if (agent is null)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
|
||||
namespace ClawdDotNet.Core.Budget;
|
||||
|
||||
/// <summary>
|
||||
/// #717: The question to a human about fill work that has found no room before its deadline.
|
||||
/// Only a human can answer it — raise the budget or move the deadline — so it goes on the
|
||||
/// board as a human's task, below the fill work it is about.
|
||||
/// </summary>
|
||||
public static class FillWorkNotice
|
||||
{
|
||||
public static TaskItem QuestionFor(TaskItem fill, TaskEstimate estimate)
|
||||
{
|
||||
var body = new StringBuilder();
|
||||
body.AppendLine($"Fill work {fill.Id} (\"{fill.Title}\") is due by {fill.FillBy:yyyy-MM-dd HH:mm} UTC " +
|
||||
"and has not found room in the budget so far.");
|
||||
body.AppendLine();
|
||||
body.AppendLine($"Assigned to: {fill.Assignee}");
|
||||
body.AppendLine($"Expected cost per run: {Describe(estimate)}");
|
||||
body.AppendLine();
|
||||
body.AppendLine("Fill work only runs on budget the rest of the day leaves unused. Please decide:");
|
||||
body.AppendLine("- raise the agent's daily budget, or");
|
||||
body.AppendLine($"- move the deadline (fill_by in {fill.Id}), or");
|
||||
body.AppendLine("- leave it: at the deadline it runs as regular work, ahead of other late work.");
|
||||
|
||||
return new TaskItem
|
||||
{
|
||||
Title = $"Fill work {fill.Id} finds no room before its deadline",
|
||||
Body = body.ToString().TrimEnd(),
|
||||
Assignee = TaskAssignee.Human,
|
||||
Type = TaskItemType.HumanInput,
|
||||
Priority = Math.Clamp(fill.Priority + 1, 1, 5),
|
||||
Status = TaskItemStatus.Todo,
|
||||
ParentId = fill.Id
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>"0.80 USD (measured over 3 runs)" — and a guess says it is one.</summary>
|
||||
public static string Describe(TaskEstimate estimate)
|
||||
{
|
||||
var usd = estimate.Run.CostUsd.ToString("0.00##", CultureInfo.InvariantCulture) + " USD";
|
||||
return estimate.Source switch
|
||||
{
|
||||
EstimateSource.Measured => $"{usd} (measured over {estimate.Samples} run(s))",
|
||||
EstimateSource.Siblings => $"{usd} (measured on {estimate.Samples} run(s) of its sibling tasks)",
|
||||
EstimateSource.Stated => $"{usd} (estimated by its author, not measured)",
|
||||
EstimateSource.AgentAverage => $"{usd} (the agent's average run, not measured on this task)",
|
||||
_ => "unknown - nothing measured and no estimate given"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ClawdDotNet.Core.Budget;
|
||||
|
||||
/// <summary>
|
||||
/// #717: Fill work and large tasks, instance-wide. Budgets stay at the agent (concept 8a);
|
||||
/// these only say when unused budget is filled and when a task counts as large.
|
||||
/// </summary>
|
||||
public sealed class FillWorkSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Local hour from which fill work may start. Late in the day the forecast for the rest of
|
||||
/// it is least uncertain — at 9 in the morning "there is room" is a guess, at 8 in the
|
||||
/// evening it is nearly a count. 0 lets fill work start any time there is room.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromHour")]
|
||||
public int FromHour { get; set; } = 18;
|
||||
|
||||
/// <summary>
|
||||
/// How many hours before its deadline fill work that found no room is taken to a human —
|
||||
/// raise the budget or move the deadline. Fill work never overruns its deadline silently.
|
||||
/// Within this window it also no longer waits for the evening.
|
||||
/// </summary>
|
||||
[JsonPropertyName("warnHoursBeforeDeadline")]
|
||||
public int WarnHoursBeforeDeadline { get; set; } = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Share of the agent's daily cost limit above which a task counts as large: it is not
|
||||
/// simply started, the agent proposes to split it or asks for a special budget.
|
||||
/// </summary>
|
||||
[JsonPropertyName("largeTaskShare")]
|
||||
public decimal LargeTaskShare { get; set; } = 0.5m;
|
||||
|
||||
[JsonIgnore]
|
||||
public TimeSpan WarnBeforeDeadline => TimeSpan.FromHours(Math.Max(0, WarnHoursBeforeDeadline));
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
|
||||
namespace ClawdDotNet.Core.Budget;
|
||||
|
||||
/// <summary>#717: Where an estimate came from — from the most to the least reliable.</summary>
|
||||
public enum EstimateSource
|
||||
{
|
||||
/// <summary>The task's own runs, measured.</summary>
|
||||
Measured,
|
||||
|
||||
/// <summary>Runs of its siblings — the other parts of the same split task.</summary>
|
||||
Siblings,
|
||||
|
||||
/// <summary>What the task's author wrote down. A guess, and shown as one.</summary>
|
||||
Stated,
|
||||
|
||||
/// <summary>The average run of the agent — says nothing about this task in particular.</summary>
|
||||
AgentAverage,
|
||||
|
||||
/// <summary>Nothing to go on.</summary>
|
||||
None
|
||||
}
|
||||
|
||||
/// <summary>#717: What one run of a task is expected to cost, and how much that is worth.</summary>
|
||||
public sealed record TaskEstimate(RunEstimate Run, EstimateSource Source, int Samples)
|
||||
{
|
||||
/// <summary>Stated or averaged over everything the agent does — not measured on this work.</summary>
|
||||
public bool IsGuess => Source is EstimateSource.Stated or EstimateSource.AgentAverage or EstimateSource.None;
|
||||
|
||||
public static TaskEstimate Nothing { get; } = new(default, EstimateSource.None, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #717: Estimates a run of one particular task — honestly.
|
||||
///
|
||||
/// <para>Costs cannot be known in advance, and models judge their own effort badly. So the
|
||||
/// code estimates from history where there is any, and says when it has none:</para>
|
||||
/// <list type="number">
|
||||
/// <item>the task's own runs (a fill task that already ran part of the way, or ran before),</item>
|
||||
/// <item>its siblings — the parts of the same split task, measured as soon as the first one ran,</item>
|
||||
/// <item>the author's estimate (<see cref="TaskItem.EstimateUsd"/>), marked as a guess,</item>
|
||||
/// <item>the agent's average run, also a guess.</item>
|
||||
/// </list>
|
||||
/// <para>After the first run the measured value replaces the guess — nothing has to be
|
||||
/// updated for that, the next estimate simply finds the run.</para>
|
||||
/// </summary>
|
||||
public sealed class TaskCostEstimator
|
||||
{
|
||||
/// <summary>How many runs are averaged — recent enough to follow a change, enough to smooth one outlier.</summary>
|
||||
public const int Sample = 10;
|
||||
|
||||
private readonly IAuditRepository? _audit;
|
||||
private readonly ITaskRepository _tasks;
|
||||
private readonly BudgetGuard _guard;
|
||||
|
||||
public TaskCostEstimator(IAuditRepository? audit, ITaskRepository tasks, BudgetGuard guard)
|
||||
{
|
||||
_audit = audit;
|
||||
_tasks = tasks;
|
||||
_guard = guard;
|
||||
}
|
||||
|
||||
public async Task<TaskEstimate> EstimateAsync(TaskItem task, CancellationToken ct)
|
||||
{
|
||||
if (_audit is not null)
|
||||
{
|
||||
var own = Measured(await _audit.ListReceiptsForTaskAsync(task.Id, ct));
|
||||
if (own.Count > 0)
|
||||
return Average(own, EstimateSource.Measured);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(task.ParentId))
|
||||
{
|
||||
var siblingRuns = new List<RunReceipt>();
|
||||
foreach (var sibling in await _tasks.ListChildrenAsync(task.ParentId, ct))
|
||||
{
|
||||
if (sibling.Id == task.Id)
|
||||
continue;
|
||||
siblingRuns.AddRange(Measured(await _audit.ListReceiptsForTaskAsync(sibling.Id, ct)));
|
||||
}
|
||||
if (siblingRuns.Count > 0)
|
||||
return Average(siblingRuns.OrderByDescending(r => r.OccurredAt).Take(Sample).ToList(), EstimateSource.Siblings);
|
||||
}
|
||||
}
|
||||
|
||||
var agentId = TaskAssignee.AgentId(task.Assignee);
|
||||
var agentAverage = agentId.Length == 0 ? default : await _guard.EstimateRunAsync(agentId, ct);
|
||||
|
||||
if (task.EstimateUsd is { } stated)
|
||||
{
|
||||
// Tokens follow the agent's own price per token, where it has one — a token limit
|
||||
// needs a token figure, and the author only wrote down money.
|
||||
var tokens = agentAverage.CostUsd > 0
|
||||
? (long)Math.Ceiling(stated / agentAverage.CostUsd * agentAverage.Tokens)
|
||||
: 0;
|
||||
return new TaskEstimate(new RunEstimate(stated, tokens), EstimateSource.Stated, 0);
|
||||
}
|
||||
|
||||
return agentAverage == default
|
||||
? TaskEstimate.Nothing
|
||||
: new TaskEstimate(agentAverage, EstimateSource.AgentAverage, 0);
|
||||
}
|
||||
|
||||
/// <summary>Runs that used something — a refusal or an abort before the first call says nothing about cost.</summary>
|
||||
private static List<RunReceipt> Measured(IReadOnlyList<RunReceipt> receipts)
|
||||
=> receipts.Where(r => r.PromptTokens + r.CompletionTokens > 0)
|
||||
.OrderByDescending(r => r.OccurredAt)
|
||||
.Take(Sample)
|
||||
.ToList();
|
||||
|
||||
private static TaskEstimate Average(IReadOnlyList<RunReceipt> runs, EstimateSource source)
|
||||
=> new(new RunEstimate(
|
||||
runs.Average(r => r.CostUsd),
|
||||
(long)Math.Ceiling(runs.Average(r => (double)(r.PromptTokens + r.CompletionTokens)))),
|
||||
source, runs.Count);
|
||||
}
|
||||
@@ -147,6 +147,10 @@ public sealed class InstanceConfig
|
||||
[JsonPropertyName("cases")]
|
||||
public Cases.CaseSettings Cases { get; set; } = new();
|
||||
|
||||
/// <summary>#717: When unused budget is filled, and when a task counts as large.</summary>
|
||||
[JsonPropertyName("fillWork")]
|
||||
public Budget.FillWorkSettings FillWork { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("maxDelegationDepth")]
|
||||
public int MaxDelegationDepth { get; set; } = 3;
|
||||
|
||||
|
||||
@@ -412,6 +412,12 @@ public sealed class SqliteStorage
|
||||
SpalteErgaenzen(connection, "Tasks", "OriginRunId", "TEXT NOT NULL DEFAULT ''");
|
||||
SpalteErgaenzen(connection, "Tasks", "OriginTaskId", "TEXT NOT NULL DEFAULT ''");
|
||||
|
||||
// #717: Fill work — a deadline instead of an appointment, the author's estimate, and
|
||||
// when a human was asked because no room was found.
|
||||
SpalteErgaenzen(connection, "Tasks", "FillBy", "TEXT NULL");
|
||||
SpalteErgaenzen(connection, "Tasks", "EstimateUsd", "TEXT NULL");
|
||||
SpalteErgaenzen(connection, "Tasks", "FillAlertedAt", "TEXT NULL");
|
||||
|
||||
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
|
||||
using var index = connection.CreateCommand();
|
||||
index.CommandText = """
|
||||
|
||||
@@ -94,6 +94,12 @@ public interface ITaskRepository
|
||||
/// </summary>
|
||||
Task<bool> ClearBudgetMissAsync(string id, DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// #717: Records that a human was asked about fill work that found no room before its
|
||||
/// deadline. Returns <c>false</c> if that had already happened — asked once.
|
||||
/// </summary>
|
||||
Task<bool> MarkFillAlertedAsync(string id, DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Reconciliation beim Start: verwaiste Claims (älter als <paramref name="leaseCutoff"/>)
|
||||
/// lösen und die betroffenen Aufgaben von <see cref="TaskItemStatus.InProgress"/> zurück
|
||||
@@ -108,5 +114,8 @@ public interface ITaskRepository
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TaskItem>> ListBlockedByAsync(string blockerId, CancellationToken ct);
|
||||
|
||||
/// <summary>#717: The tasks directly below a task in the goal chain (#714), archived ones included.</summary>
|
||||
Task<IReadOnlyList<TaskItem>> ListChildrenAsync(string parentId, CancellationToken ct);
|
||||
|
||||
Task<int> CountAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -34,11 +34,11 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
INSERT INTO Tasks
|
||||
(Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz,
|
||||
RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName,
|
||||
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, CreatedBy, ParentId, OriginRunId, OriginTaskId)
|
||||
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, CreatedBy, ParentId, OriginRunId, OriginTaskId, FillBy, EstimateUsd)
|
||||
VALUES
|
||||
(@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz,
|
||||
@requireApproval, @acceptance, @blockedBy, @market, @body, @fileName,
|
||||
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt, @createdBy, @parentId, @originRun, @originTask);
|
||||
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt, @createdBy, @parentId, @originRun, @originTask, @fillBy, @estimateUsd);
|
||||
""";
|
||||
BindDefinition(insert, task);
|
||||
insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status));
|
||||
@@ -64,6 +64,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
ParentId = @parentId,
|
||||
OriginRunId = CASE WHEN @originRun = '' THEN OriginRunId ELSE @originRun END,
|
||||
OriginTaskId = CASE WHEN @originTask = '' THEN OriginTaskId ELSE @originTask END,
|
||||
FillBy = @fillBy, EstimateUsd = @estimateUsd,
|
||||
UpdatedAt = @updatedAt
|
||||
WHERE Id = @id
|
||||
""";
|
||||
@@ -259,6 +260,17 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}, ct);
|
||||
|
||||
public Task<bool> MarkFillAlertedAsync(string id, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE Tasks SET FillAlertedAt = @now WHERE Id = @id AND FillAlertedAt IS NULL";
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}, ct);
|
||||
|
||||
public Task<int> ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
@@ -321,6 +333,21 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TaskItem>> ListChildrenAsync(string parentId, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + Columns + " FROM Tasks WHERE ParentId = @parent COLLATE NOCASE";
|
||||
cmd.Parameters.AddWithValue("@parent", parentId.Trim());
|
||||
|
||||
var results = new List<TaskItem>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(Read(reader));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<int> CountAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
@@ -335,7 +362,8 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
"Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " +
|
||||
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
|
||||
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, "
|
||||
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy, ParentId, OriginRunId, OriginTaskId";
|
||||
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy, ParentId, OriginRunId, OriginTaskId, "
|
||||
+ "FillBy, EstimateUsd, FillAlertedAt";
|
||||
|
||||
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
|
||||
{
|
||||
@@ -361,6 +389,10 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
// #697: Stamped once; a file written by hand, or re-imported, keeps what is known.
|
||||
cmd.Parameters.AddWithValue("@originRun", task.OriginRunId ?? "");
|
||||
cmd.Parameters.AddWithValue("@originTask", task.OriginTaskId ?? "");
|
||||
// #717: Part of the definition — the file is the truth, so a file without it clears it.
|
||||
cmd.Parameters.AddWithValue("@fillBy", task.FillBy is { } fillBy ? Format(fillBy) : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@estimateUsd",
|
||||
task.EstimateUsd is { } usd ? usd.ToString(System.Globalization.CultureInfo.InvariantCulture) : DBNull.Value);
|
||||
}
|
||||
|
||||
private static async Task<TaskItem?> ReadAsync(SqliteConnection conn, string id, CancellationToken ct)
|
||||
@@ -415,7 +447,11 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
CreatedBy = r.GetString(26),
|
||||
ParentId = r.GetString(27),
|
||||
OriginRunId = r.GetString(28),
|
||||
OriginTaskId = r.GetString(29)
|
||||
OriginTaskId = r.GetString(29),
|
||||
FillBy = r.IsDBNull(30) ? null : Parse(r.GetString(30)),
|
||||
EstimateUsd = r.IsDBNull(31) ? null
|
||||
: decimal.Parse(r.GetString(31), System.Globalization.CultureInfo.InvariantCulture),
|
||||
FillAlertedAt = r.IsDBNull(32) ? null : Parse(r.GetString(32))
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -156,6 +156,8 @@ public static class TaskFrontmatter
|
||||
ParentId = scalars.GetValueOrDefault("parent", ""),
|
||||
OriginRunId = scalars.GetValueOrDefault("origin_run", ""),
|
||||
OriginTaskId = scalars.GetValueOrDefault("origin_task", ""),
|
||||
FillBy = ParseUtc(scalars.GetValueOrDefault("fill_by")),
|
||||
EstimateUsd = ParseUsd(scalars.GetValueOrDefault("estimate_usd")),
|
||||
Body = body
|
||||
};
|
||||
return true;
|
||||
@@ -182,6 +184,12 @@ public static class TaskFrontmatter
|
||||
if (!string.IsNullOrWhiteSpace(task.OriginTaskId))
|
||||
sb.Append($"origin_task: {Scalar(task.OriginTaskId)}\n");
|
||||
|
||||
// #717: Fill work — a deadline instead of an appointment — and the author's estimate.
|
||||
if (task.FillBy is { } fillBy)
|
||||
sb.Append($"fill_by: {fillBy.ToUniversalTime():yyyy-MM-ddTHH:mm:ssZ}\n");
|
||||
if (task.EstimateUsd is { } estimate)
|
||||
sb.Append($"estimate_usd: {estimate.ToString(System.Globalization.CultureInfo.InvariantCulture)}\n");
|
||||
|
||||
if (task.When is { } w)
|
||||
{
|
||||
sb.Append("when:\n");
|
||||
@@ -320,6 +328,31 @@ public static class TaskFrontmatter
|
||||
private static int ParsePriority(string? value)
|
||||
=> int.TryParse(value?.Trim(), out var p) ? Math.Clamp(p, 1, 5) : 3;
|
||||
|
||||
/// <summary>
|
||||
/// #717: A point in time, read as UTC unless it says otherwise. A bare date means the end
|
||||
/// of that day — "by the 30th" includes the 30th. Unreadable is no deadline, not an error.
|
||||
/// </summary>
|
||||
public static DateTime? ParseUtc(string? value)
|
||||
{
|
||||
var v = value?.Trim();
|
||||
if (string.IsNullOrEmpty(v))
|
||||
return null;
|
||||
var styles = System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal;
|
||||
if (DateOnly.TryParseExact(v, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None, out var day))
|
||||
return DateTime.SpecifyKind(day.AddDays(1).ToDateTime(TimeOnly.MinValue), DateTimeKind.Utc);
|
||||
return DateTime.TryParse(v, System.Globalization.CultureInfo.InvariantCulture, styles, out var at)
|
||||
? DateTime.SpecifyKind(at, DateTimeKind.Utc)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>#717: An amount in USD; negative or unreadable is no estimate.</summary>
|
||||
public static decimal? ParseUsd(string? value)
|
||||
=> decimal.TryParse(value?.Trim().TrimStart('$'), System.Globalization.NumberStyles.Number,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var usd) && usd >= 0
|
||||
? usd
|
||||
: null;
|
||||
|
||||
private static string EmptyToDefault(string? value, string fallback)
|
||||
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
|
||||
@@ -252,6 +252,31 @@ public sealed record TaskItem
|
||||
/// <summary>#697: The task that run was working on, if any — the task this one arose from.</summary>
|
||||
public string OriginTaskId { get; init; } = "";
|
||||
|
||||
// ─── Fill work and estimates (#717) ───
|
||||
|
||||
/// <summary>
|
||||
/// #717: Fill work — "at the latest by" instead of an appointment (UTC). The task runs when
|
||||
/// the day's budget has room that would otherwise lapse, and before this point at the
|
||||
/// latest. Only for one-time tasks; a recurring task has its appointments.
|
||||
/// </summary>
|
||||
public DateTime? FillBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// #717: What the task's author expects one run to cost, in USD — a guess, and marked as
|
||||
/// one. Used only while there is nothing measured: the task's own runs, or those of its
|
||||
/// siblings, replace it (see <see cref="Budget.TaskCostEstimator"/>).
|
||||
/// </summary>
|
||||
public decimal? EstimateUsd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// #717: When a human was asked because the fill work found no room before its deadline.
|
||||
/// Asked once; execution state, not part of the file.
|
||||
/// </summary>
|
||||
public DateTime? FillAlertedAt { get; init; }
|
||||
|
||||
/// <summary>#717: One-time work with a deadline instead of an appointment.</summary>
|
||||
public bool IsFillWork => FillBy is not null && !IsRecurring;
|
||||
|
||||
/// <summary>A one-time task that was refused for lack of budget and carried over.</summary>
|
||||
public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0;
|
||||
|
||||
|
||||
@@ -53,6 +53,29 @@ public interface ILateRunGate
|
||||
Task<bool> HasRoomAsync(TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>#717: Whether fill work may start now.</summary>
|
||||
public enum FillVerdict
|
||||
{
|
||||
/// <summary>There is room — it starts.</summary>
|
||||
Run,
|
||||
|
||||
/// <summary>Too early in the day; the forecast is still a guess.</summary>
|
||||
NotYet,
|
||||
|
||||
/// <summary>The rest of the day needs the budget.</summary>
|
||||
NoRoom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #717: Decides whether fill work — one-time work with a deadline instead of an appointment —
|
||||
/// may use the budget the day would otherwise leave unused. Without one, fill work runs like
|
||||
/// late work: after everything else of the tick.
|
||||
/// </summary>
|
||||
public interface IFillGate
|
||||
{
|
||||
Task<FillVerdict> CheckAsync(TaskItem fillTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #716: The day ahead — where the budget will not reach the end of today's schedule.
|
||||
/// </summary>
|
||||
@@ -139,6 +162,20 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
private readonly IBudgetOutlook? _outlook;
|
||||
private readonly ITaskTreeWatch? _treeWatch;
|
||||
|
||||
/// <summary>#717: Whether fill work may use today's unused budget.</summary>
|
||||
private readonly IFillGate? _fillGate;
|
||||
|
||||
/// <summary>#717: Asks a human about fill work that found no room before its deadline.</summary>
|
||||
private readonly Func<TaskItem, CancellationToken, Task>? _askAboutFill;
|
||||
|
||||
private readonly Func<TimeSpan> _fillWarnBefore;
|
||||
|
||||
/// <summary>#717: Fill work already reported as past its deadline — once per task.</summary>
|
||||
private readonly HashSet<string> _warnedOverdueFill = [];
|
||||
|
||||
/// <summary>How many fill tasks waited for room at the last tick.</summary>
|
||||
private int _lastWaitingFill;
|
||||
|
||||
/// <summary>How often the day ahead is looked at — a forecast, not a watchdog.</summary>
|
||||
public static readonly TimeSpan OutlookInterval = TimeSpan.FromMinutes(30);
|
||||
|
||||
@@ -165,9 +202,15 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
Func<TaskItem, BudgetMissPolicy>? budgetMissPolicy = null,
|
||||
ILateRunGate? lateRunGate = null,
|
||||
IBudgetOutlook? budgetOutlook = null,
|
||||
ITaskTreeWatch? treeWatch = null)
|
||||
ITaskTreeWatch? treeWatch = null,
|
||||
IFillGate? fillGate = null,
|
||||
Func<TaskItem, CancellationToken, Task>? askAboutFill = null,
|
||||
Func<TimeSpan>? fillWarnBefore = null)
|
||||
{
|
||||
_treeWatch = treeWatch;
|
||||
_fillGate = fillGate;
|
||||
_askAboutFill = askAboutFill;
|
||||
_fillWarnBefore = fillWarnBefore ?? (() => TimeSpan.FromHours(24));
|
||||
_repo = repo;
|
||||
_dispatcher = dispatcher;
|
||||
_market = market ?? new AlwaysOpenMarketCalendar();
|
||||
@@ -293,7 +336,7 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
if (_treeWatch is not null)
|
||||
await _treeWatch.CheckAsync(now, ct);
|
||||
|
||||
var due = new List<(TaskItem Task, string Occurrence, bool Late)>();
|
||||
var due = new List<(TaskItem Task, string Occurrence, RunKind Kind)>();
|
||||
foreach (var task in candidates)
|
||||
{
|
||||
if (task.Status != TaskItemStatus.Todo)
|
||||
@@ -346,24 +389,45 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
continue;
|
||||
|
||||
var occ = TaskSchedule.DueOccurrence(task, now);
|
||||
|
||||
// #717: Fill work waits for room — until its deadline. Past it, the deadline was
|
||||
// the promise, and it runs like any other due work.
|
||||
if (task.IsFillWork && occ is not null)
|
||||
{
|
||||
await AskAboutFillIfDueAsync(task, now, ct);
|
||||
if (now < task.FillBy!.Value)
|
||||
{
|
||||
due.Add((task, occ, RunKind.Fill));
|
||||
continue;
|
||||
}
|
||||
if (_warnedOverdueFill.Add(task.Id))
|
||||
_logger.LogWarning(
|
||||
"Fill work {TaskId} ({Title}) reached its deadline {Deadline:yyyy-MM-dd HH:mm} UTC without "
|
||||
+ "finding room — it now runs as regular work", task.Id, task.Title, task.FillBy);
|
||||
}
|
||||
|
||||
if (occ is not null)
|
||||
{
|
||||
// A regular occurrence also covers a pending catch-up: one run, not two.
|
||||
due.Add((task, occ, Late: task.IsCarriedOver));
|
||||
due.Add((task, occ, task.IsCarriedOver ? RunKind.Late : RunKind.Regular));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (task.CatchUpPending && !deferred)
|
||||
due.Add((task, Iso(now), Late: true));
|
||||
due.Add((task, Iso(now), RunKind.Late));
|
||||
}
|
||||
|
||||
// #716: The new day has right of way — carried-over and catch-up runs come after
|
||||
// everything regularly due. Within each group: priority, then the oldest occurrence.
|
||||
// Occurrence keys are ISO-8601 UTC ("O"), so ordinal order is time order.
|
||||
// everything regularly due, and fill work (#717) after those: late work was due,
|
||||
// fill work never was. Within each group: priority, then the oldest occurrence —
|
||||
// for fill work the nearest deadline first. Occurrence keys are ISO-8601 UTC ("O"),
|
||||
// so ordinal order is time order.
|
||||
due.Sort((a, b) =>
|
||||
{
|
||||
if (a.Late != b.Late)
|
||||
return a.Late ? 1 : -1;
|
||||
if (a.Kind != b.Kind)
|
||||
return a.Kind.CompareTo(b.Kind);
|
||||
if (a.Kind == RunKind.Fill && Nullable.Compare(a.Task.FillBy, b.Task.FillBy) is var byDeadline and not 0)
|
||||
return byDeadline;
|
||||
var byPriority = b.Task.Priority.CompareTo(a.Task.Priority);
|
||||
return byPriority != 0 ? byPriority : string.CompareOrdinal(a.Occurrence, b.Occurrence);
|
||||
});
|
||||
@@ -371,29 +435,57 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
var launched = new List<Task>();
|
||||
var waiting = 0;
|
||||
var waitingForHeadroom = 0;
|
||||
foreach (var (task, occ, late) in due)
|
||||
var waitingFill = 0;
|
||||
|
||||
// #717: Agents with regular work that had to wait this tick — their fill work waits too.
|
||||
var agentsWithWaitingWork = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (task, occ, kind) in due)
|
||||
{
|
||||
// OpenCount also counts runs started by hand (RunTaskNowAsync) — they take a
|
||||
// slot, even though they did not wait for one.
|
||||
if (OpenCount >= _maxParallelRuns)
|
||||
{
|
||||
waiting++;
|
||||
if (kind != RunKind.Fill)
|
||||
agentsWithWaitingWork.Add(TaskAssignee.AgentId(task.Assignee));
|
||||
continue;
|
||||
}
|
||||
|
||||
// #716: Late work only uses what the rest of the day does not need.
|
||||
if (late && _lateRunGate is not null
|
||||
if (kind == RunKind.Late && _lateRunGate is not null
|
||||
&& !await _lateRunGate.HasRoomAsync(task, candidates, now, ct))
|
||||
{
|
||||
waitingForHeadroom++;
|
||||
agentsWithWaitingWork.Add(TaskAssignee.AgentId(task.Assignee));
|
||||
continue;
|
||||
}
|
||||
|
||||
// #717: Fill work only when nothing of the agent's own waits, and only in room
|
||||
// the day would otherwise leave unused. That is decided here, not by the model.
|
||||
if (kind == RunKind.Fill)
|
||||
{
|
||||
if (agentsWithWaitingWork.Contains(TaskAssignee.AgentId(task.Assignee))
|
||||
|| (_fillGate is not null && await _fillGate.CheckAsync(task, candidates, now, ct) != FillVerdict.Run))
|
||||
{
|
||||
waitingFill++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
if (await _repo.TryClaimAsync(task.Id, occ, token, now, leaseCutoff, ct))
|
||||
launched.Add(Track(RunLoggedAsync(task, token, ct)));
|
||||
}
|
||||
|
||||
if (waitingFill != _lastWaitingFill)
|
||||
{
|
||||
if (waitingFill > 0)
|
||||
_logger.LogInformation(
|
||||
"{Waiting} fill task(s) waiting for unused budget — they run when the rest of the day leaves room",
|
||||
waitingFill);
|
||||
_lastWaitingFill = waitingFill;
|
||||
}
|
||||
|
||||
// A cap that hides its effect is worse than none (the lesson of #673): say when
|
||||
// due work had to wait, but only when that changes — the tick runs every minute.
|
||||
if (waiting != _lastWaiting)
|
||||
@@ -420,6 +512,34 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
return launched;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #717: Fill work that has not found room by <c>deadline - warnBefore</c> is taken to a
|
||||
/// human once: raise the budget or move the deadline. Still waiting at that point means
|
||||
/// it has not run — a run that took place would have finished it.
|
||||
/// </summary>
|
||||
private async Task AskAboutFillIfDueAsync(TaskItem task, DateTime now, CancellationToken ct)
|
||||
{
|
||||
if (_askAboutFill is null || task.FillAlertedAt is not null || task.FillBy is not { } deadline
|
||||
|| now < deadline - _fillWarnBefore())
|
||||
return;
|
||||
|
||||
// Marked first: asking twice is worse than asking once and failing to log it.
|
||||
if (!await _repo.MarkFillAlertedAsync(task.Id, now, ct))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await _askAboutFill(task, ct);
|
||||
_logger.LogWarning(
|
||||
"Fill work {TaskId} ({Title}) has found no room and is due by {Deadline:yyyy-MM-dd HH:mm} UTC — "
|
||||
+ "a human was asked to raise the budget or move the deadline", task.Id, task.Title, deadline);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Could not ask a human about fill work {TaskId}", task.Id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a claimed task and logs what escapes it. The tick loop does not await its runs,
|
||||
/// so an exception here would otherwise go unobserved.
|
||||
@@ -698,6 +818,14 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
|
||||
private static string Iso(DateTime utc) => utc.ToUniversalTime().ToString("O");
|
||||
|
||||
/// <summary>The order in which due work is started: what is due, what is late, what fills.</summary>
|
||||
private enum RunKind
|
||||
{
|
||||
Regular,
|
||||
Late,
|
||||
Fill
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-Dispatch: Wird eine Aufgabe fertig, werden die auf sie wartenden Aufgaben
|
||||
/// freigegeben, sobald <b>alle</b> ihre Blocker erledigt sind.
|
||||
|
||||
Reference in New Issue
Block a user