3 Commits
Author SHA1 Message Date
RichardandClaude Opus 5.5 b5f65b8cf7 Merge feat/717-fuellarbeit: Fuellarbeit, Grossaufgaben und Sonderbudget (#717)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:47:41 +02:00
RichardandClaude Opus 5.5 67649559f5 Grossaufgaben und Sonderbudget (#717, Stufe 2+3)
Eine Aufgabe, deren eigene Schaetzung (gemessen, Geschwister oder Autor)
ueber der Haelfte der Tageskostengrenze liegt, bekommt zuerst einen
Planungslauf: teilen (Teile unter der Elternaufgabe) oder Sonderbudget
beantragen. Danach wartet sie auf ihre Teile bzw. die Freigabe und laeuft
einmal zum Zusammenfuehren.

Sonderbudget ueber Escalation.request_budget, ausnahmslos freigabepflichtig
(auch gegen Werkzeugkonfiguration und Dauerfreigabe). Laeufe im Baum ziehen
den Topf statt der Tagesgrenzen, tragen BudgetPot in Verbrauch und Beleg und
sind in der Kostenanalyse (nach Budget) und auf der Aufgabenkarte gesondert
sichtbar. Aufgebraucht zaehlen sie wieder gegen den Tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:47:41 +02:00
RichardandClaude Opus 5.5 183e922278 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>
2026-09-25 15:34:15 +02:00
41 changed files with 2303 additions and 86 deletions
+35
View File
@@ -1099,6 +1099,41 @@ ihren eigenen Aufwand schlecht. Wo es Verlauf gibt, schätzt der Code aus dem Ve
Sonst schätzt der Agent, und die Zahl ist als Schätzung gekennzeichnet. Nach dem ersten
Teillauf ersetzt der gemessene Wert die Schätzung.
*Gebaut am 2026-09-25 (#717).* So umgesetzt, mit den Entscheidungen, die das Konzept offen
ließ:
- **Füllarbeit** ist eine einmalige Aufgabe mit `fill_by` statt Termin (Werkzeug, WebUI,
Datei). Sie kommt nach der regulären und der nachgeholten Arbeit (#716), mit der
nächsten Frist zuerst. Sie startet nur, wenn keine andere Arbeit desselben Agenten
wartet, ab `fillWork.fromHour` (Vorgabe 18 Uhr) und nur, wenn ihre eigene Schätzung in
den Spielraum aus #716 passt. Ohne Budgetgrenze gibt es nichts zu füllen, dann läuft
sie sofort.
- **Frist:** `fillWork.warnHoursBeforeDeadline` (Vorgabe 24) vor der Frist bekommt ein
Mensch einmal eine Aufgabe unter der Füllarbeit: Budget erhöhen oder Frist verschieben.
In diesem Fenster wartet sie auch nicht mehr auf den Abend. *Entscheidung:* Ist die
Frist erreicht, läuft sie als reguläre Arbeit. Die Frist war die Zusage, und der Mensch
wurde vorher gefragt.
- **Schätzung** (`TaskCostEstimator`): eigene gemessene Läufe, dann die Läufe der
Geschwister unter derselben Elternaufgabe, dann `estimate_usd` des Autors, zuletzt das
Mittel des Agenten. Die letzten beiden sind überall als Schätzung gekennzeichnet.
- **Großaufgabe:** Groß ist eine Aufgabe erst mit einer Schätzung *dieser* Aufgabe
(gemessen, Geschwister oder Autor) über `fillWork.largeTaskShare` (Vorgabe 50 %) der
Tageskostengrenze. Das Mittel des Agenten reicht dafür nicht, sonst hielte ein Agent
mit teuren Läufen jede Aufgabe zur Planung an. Ihr erster Lauf ist ein Planungslauf mit
Anweisung vom System, danach wartet sie. Hat der Agent sie geteilt, wartet sie auf die
Teile und läuft danach einmal, um sie zusammenzuführen. Andernfalls wartet sie auf das
Sonderbudget, mit einer Notiz an der Aufgabe.
- **Sonderbudget** über die implizite Eskalations-Aktion `request_budget`, nur für eigene
Aufgaben. Sie steht in der Liste der ausnahmslos freigabepflichtigen Aktionen, also
lässt weder Werkzeugkonfiguration noch Dauerfreigabe sie ohne Menschen durch. Läufe im
Baum ziehen den Topf und nicht die Tagesgrenzen von Agent und Instanz. Sie tragen die
Markierung `BudgetPot` in Verbrauch und Beleg: Die Verbrauchsanzeige zeigt weiter alles,
die Budgetprüfung zählt sie nicht, die Kostenanalyse gruppiert „nach Budget“, und die
Aufgabenkarte zeigt „verbraucht X von Y“. *Entscheidung:* Ist der Topf aufgebraucht,
zählen die Läufe des Baums wieder gegen den Tag, wie jede andere Arbeit, und das steht
einmal im Log. Anhalten wäre die andere Möglichkeit gewesen. Sie hätte halb fertige
Arbeit liegen lassen, die der Mensch schon für wert befunden hatte.
### Was die Auswertung dafür braucht
- **Die Einheit als Dimension** in Filtern und Zeitverlauf der Verbrauchsansicht (#660) —
+30 -3
View File
@@ -67,6 +67,9 @@ public sealed class AppHost : IAsyncDisposable
/// <summary>#689: The desk a human decides at; <c>null</c> without a task board.</summary>
public Core.Cases.ICaseDesk? CaseDesk => Engine?.CaseDesk;
/// <summary>#717: Special budgets — one pot per task tree, approved by a human.</summary>
public Core.Budget.ISpecialBudgetRepository? SpecialBudgets { get; init; }
/// <summary>
/// Zustandsspeicher und Audit-Log der Instanz. Nicht oeffentlich — sie gehoeren dem
/// Aufbau, nicht den Oberflaechen; gebraucht werden sie hier fuer die Sichtung
@@ -548,6 +551,7 @@ public sealed class AppHost : IAsyncDisposable
var usage = new SqliteUsageRepository(storage);
var transcripts = new Core.Transcripts.SqliteRunTranscriptStore(storage);
var cases = new Core.Cases.SqliteCaseRepository(storage);
var specialBudgets = new SqliteSpecialBudgetRepository(storage);
// Preise fürs Budget: Ohne sie greift nur die Token-Grenze.
var pricing = new ModelPricingCatalog();
@@ -565,7 +569,9 @@ public sealed class AppHost : IAsyncDisposable
TranscriptStore = transcripts,
DefaultTranscriptRetention = () => instance.PromptHistory.RetentionDays,
MaxDelegationDepth = instance.MaxDelegationDepth,
DelegationGateTimeout = TimeSpan.FromSeconds(instance.DelegationGateTimeoutSeconds)
DelegationGateTimeout = TimeSpan.FromSeconds(instance.DelegationGateTimeoutSeconds),
// #717: runs in a funded task tree draw on its pot, not on the daily limits.
SpecialBudgets = specialBudgets
};
engine.SetAgentConfigProvider(
@@ -607,6 +613,7 @@ public sealed class AppHost : IAsyncDisposable
Usage = usage,
Transcripts = transcripts,
Cases = cases,
SpecialBudgets = specialBudgets,
Insights = new RunInsights(audit, transcripts, board, staging,
agentId => instance.Agents.FirstOrDefault(a => a.AgentId == agentId) is { } agent
? engine.TranscriptRetentionFor(agent)
@@ -760,8 +767,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 +807,21 @@ 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,
// #717: A large task is not simply started — its run proposes a split or a special budget.
largeTaskCheck: estimator is null
? null
: new LargeTaskCheck(estimator, taskRepository, engine.SpecialBudgets, () => instance.Agents, () => instance.FillWork),
noteOnTask: async (taskId, note, ct) => await board.AddCommentAsync(taskId, "system", note, ct));
// Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
_ = Task.Run(async () =>
@@ -21,7 +21,10 @@ public sealed record TaskInput(
string? TimeZone = null,
string Acceptance = "",
int Priority = 3,
string ParentId = "");
string ParentId = "",
// #717: fill work — at the latest by (ISO or yyyy-MM-dd), and the author's estimate per run.
string? FillBy = null,
decimal? EstimateUsd = null);
/// <summary>
/// Das Taskboard fuer die Oberflaechen (#594): anlegen, aendern, pausieren, sofort ausfuehren,
@@ -88,6 +91,8 @@ public sealed class TaskAdministration(
return AdminResult.Failure(fehler);
if (await CheckParentAsync(null, input.ParentId, ct) is { } parentProblem)
return AdminResult.Failure(parentProblem);
if (CheckFill(input, when, out var fillBy) is { } fillProblem)
return AdminResult.Failure(fillProblem);
var created = await board.CreateAsync(new TaskItem
{
@@ -99,7 +104,9 @@ public sealed class TaskAdministration(
When = when,
Status = TaskItemStatus.Todo,
Type = TaskItemType.Work,
ParentId = input.ParentId.Trim()
ParentId = input.ParentId.Trim(),
FillBy = fillBy,
EstimateUsd = input.EstimateUsd
}, ct);
return AdminResult.Success(MitHinweis($"Aufgabe '{created.Title}' angelegt ({created.Id}).", created));
@@ -114,6 +121,8 @@ public sealed class TaskAdministration(
return AdminResult.Failure(fehler);
if (await CheckParentAsync(id, input.ParentId, ct) is { } parentProblem)
return AdminResult.Failure(parentProblem);
if (CheckFill(input, when, out var fillBy) is { } fillProblem)
return AdminResult.Failure(fillProblem);
var updated = await board.UpdateAsync(id, current => current with
{
@@ -123,7 +132,9 @@ public sealed class TaskAdministration(
Priority = Math.Clamp(input.Priority, 1, 5),
Assignee = assignee,
When = when,
ParentId = input.ParentId.Trim()
ParentId = input.ParentId.Trim(),
FillBy = fillBy,
EstimateUsd = input.EstimateUsd
}, ct);
return updated is null
@@ -131,6 +142,27 @@ public sealed class TaskAdministration(
: AdminResult.Success(MitHinweis($"Aufgabe '{updated.Title}' gespeichert.", updated));
}
/// <summary>
/// #717: Fill work is one-time, and its deadline lies ahead — a recurring schedule and a
/// deadline contradict each other.
/// </summary>
private static string? CheckFill(TaskInput input, TaskWhen? when, out DateTime? fillBy)
{
fillBy = null;
if (input.EstimateUsd is < 0)
return "The estimate cannot be negative.";
if (string.IsNullOrWhiteSpace(input.FillBy))
return null;
if (TaskFrontmatter.ParseUtc(input.FillBy) is not { } parsed)
return $"'{input.FillBy}' is not a point in time. Use a date (yyyy-MM-dd) or an ISO timestamp.";
if (when is { Kind: TaskWhenKind.Cron or TaskWhenKind.Every })
return "Fill work is one-time: a deadline and a recurring schedule do not go together.";
if (parsed <= DateTime.UtcNow)
return "The deadline of fill work has to lie ahead.";
fillBy = parsed;
return null;
}
/// <summary>#714: The parent must exist and must not lie below the task itself.</summary>
private async Task<string?> CheckParentAsync(string? taskId, string parentId, CancellationToken ct)
{
@@ -31,10 +31,10 @@ public sealed class SqliteUsageRepository : IUsageRepository
cmd.CommandText = """
INSERT INTO RunUsage
(AgentId, Model, PromptTokens, CompletionTokens, CachedTokens,
CostUsd, CostIsKnown, Status, StepCount, DurationMs, OccurredAt, UsageDate)
CostUsd, CostIsKnown, Status, StepCount, DurationMs, OccurredAt, UsageDate, BudgetPot)
VALUES
(@agentId, @model, @prompt, @completion, @cached,
@cost, @costKnown, @status, @steps, @duration, @occurredAt, @usageDate)
@cost, @costKnown, @status, @steps, @duration, @occurredAt, @usageDate, @pot)
""";
cmd.Parameters.AddWithValue("@agentId", usage.AgentId);
@@ -49,16 +49,24 @@ public sealed class SqliteUsageRepository : IUsageRepository
cmd.Parameters.AddWithValue("@duration", usage.DurationMs);
cmd.Parameters.AddWithValue("@occurredAt", occurredAt.ToString("O"));
cmd.Parameters.AddWithValue("@usageDate", FormatDate(DateOnly.FromDateTime(occurredAt)));
cmd.Parameters.AddWithValue("@pot", usage.BudgetPot ?? "");
await cmd.ExecuteNonQueryAsync(ct);
}, ct);
public async Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
public Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
=> GetDailyCoreAsync(date, agentId, withoutPots: false, ct);
public Task<DailyUsage> GetDailyForBudgetAsync(DateOnly date, string agentId, CancellationToken ct)
=> GetDailyCoreAsync(date, agentId, withoutPots: true, ct);
private async Task<DailyUsage> GetDailyCoreAsync(DateOnly date, string agentId, bool withoutPots, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
var filterAgent = !string.IsNullOrWhiteSpace(agentId);
var filterPots = withoutPots ? " AND BudgetPot = ''" : "";
cmd.CommandText = $"""
SELECT COALESCE(SUM(PromptTokens), 0),
@@ -66,7 +74,7 @@ public sealed class SqliteUsageRepository : IUsageRepository
COUNT(*),
COALESCE(SUM(CASE WHEN CostIsKnown = 0 THEN 1 ELSE 0 END), 0)
FROM RunUsage
WHERE UsageDate = @date {(filterAgent ? "AND AgentId = @agentId" : "")}
WHERE UsageDate = @date {(filterAgent ? "AND AgentId = @agentId" : "")}{filterPots}
""";
cmd.Parameters.AddWithValue("@date", FormatDate(date));
if (filterAgent)
@@ -88,7 +96,7 @@ public sealed class SqliteUsageRepository : IUsageRepository
using var costCmd = conn.CreateCommand();
costCmd.CommandText = $"""
SELECT CostUsd FROM RunUsage
WHERE UsageDate = @date {(filterAgent ? "AND AgentId = @agentId" : "")}
WHERE UsageDate = @date {(filterAgent ? "AND AgentId = @agentId" : "")}{filterPots}
""";
costCmd.Parameters.AddWithValue("@date", FormatDate(date));
if (filterAgent)
@@ -134,6 +142,9 @@ public sealed class SqliteUsageRepository : IUsageRepository
CostUsd, CostIsKnown, Status, StepCount, DurationMs, OccurredAt
FROM RunUsage
WHERE AgentId = @agent COLLATE NOCASE
-- #717: a special budget's runs are the large kind; they would inflate the
-- estimate of the agent's ordinary run.
AND BudgetPot = ''
ORDER BY OccurredAt DESC
LIMIT @limit
""";
@@ -28,6 +28,12 @@ public sealed record RunUsage
public long DurationMs { get; init; }
public DateTime OccurredAt { get; init; }
/// <summary>
/// #717: The task whose special budget the run drew on; empty for the daily budget. Such
/// runs do not count against the daily limits — that is what the pot is for.
/// </summary>
public string BudgetPot { get; init; } = "";
}
/// <summary>Verbrauch eines Tages, aggregiert.</summary>
@@ -51,6 +57,13 @@ public interface IUsageRepository
/// <summary>Tagesverbrauch eines Agenten. <paramref name="agentId"/> leer = ganze Instanz.</summary>
Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct);
/// <summary>
/// #717: What counts against the daily limits — the day's usage without runs that drew on
/// a special budget. <see cref="GetDailyAsync"/> keeps showing everything that was used.
/// </summary>
Task<DailyUsage> GetDailyForBudgetAsync(DateOnly date, string agentId, CancellationToken ct)
=> GetDailyAsync(date, agentId, ct);
Task<IReadOnlyList<RunUsage>> GetRecentAsync(int limit, CancellationToken ct);
/// <summary>
@@ -88,7 +88,7 @@ public sealed class UsageAnalytics(
}
/// <param name="bucket"><c>hour</c> or <c>day</c>.</param>
/// <param name="groupBy"><c>agent</c>, <c>model</c> or <c>source</c>.</param>
/// <param name="groupBy"><c>agent</c>, <c>model</c>, <c>source</c> or <c>budget</c> (#717: daily or a special budget).</param>
/// <param name="utcOffset">Offset of the viewer's local time - a "day" is the viewer's day.</param>
public async Task<IReadOnlyList<SeriesPoint>> SeriesAsync(
RunQuery q, string bucket, string groupBy, TimeSpan utcOffset, CancellationToken ct)
@@ -216,6 +216,8 @@ public sealed class UsageAnalytics(
{
"model" => string.IsNullOrWhiteSpace(r.Model) ? "(unknown)" : r.Model,
"source" => r.Source,
// #717: A special budget is visible apart — it is outside the daily limits.
"budget" => string.IsNullOrWhiteSpace(r.BudgetPot) ? "daily" : "special:" + r.BudgetPot,
_ => r.AgentId
};
@@ -127,6 +127,12 @@ public sealed record RunReceipt
/// chain. With it, a result can be followed back to the run that asked for it.
/// </summary>
public string? CallerRunId { get; init; }
/// <summary>
/// #717: The task whose special budget the run drew on; <c>null</c> for a run on the daily
/// budget. Keeps the pot visible apart in the cost analysis.
/// </summary>
public string? BudgetPot { get; init; }
}
/// <summary>
@@ -46,11 +46,11 @@ public sealed class SqliteAuditRepository : IAuditRepository
INSERT INTO RunReceipts
(RunId, AgentId, Model, Source, TaskId, Status, StepCount,
PromptTokens, CompletionTokens, CachedTokens, CostUsd, CostIsKnown,
DurationMs, ResultRef, OccurredAt, CallerRunId)
DurationMs, ResultRef, OccurredAt, CallerRunId, BudgetPot)
VALUES
(@runId, @agentId, @model, @source, @taskId, @status, @stepCount,
@prompt, @completion, @cached, @cost, @costKnown,
@durationMs, @resultRef, @occurredAt, @callerRunId)
@durationMs, @resultRef, @occurredAt, @callerRunId, @budgetPot)
""";
cmd.Parameters.AddWithValue("@runId", receipt.RunId);
cmd.Parameters.AddWithValue("@agentId", receipt.AgentId);
@@ -68,6 +68,7 @@ public sealed class SqliteAuditRepository : IAuditRepository
cmd.Parameters.AddWithValue("@resultRef", receipt.ResultRef);
cmd.Parameters.AddWithValue("@occurredAt", Format(receipt.OccurredAt));
cmd.Parameters.AddWithValue("@callerRunId", (object?)receipt.CallerRunId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@budgetPot", (object?)receipt.BudgetPot ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}, ct);
@@ -264,7 +265,7 @@ public sealed class SqliteAuditRepository : IAuditRepository
private const string ReceiptColumns =
"Id, RunId, AgentId, Model, Source, TaskId, Status, StepCount, PromptTokens, " +
"CompletionTokens, CachedTokens, CostUsd, CostIsKnown, DurationMs, ResultRef, OccurredAt, CallerRunId";
"CompletionTokens, CachedTokens, CostUsd, CostIsKnown, DurationMs, ResultRef, OccurredAt, CallerRunId, BudgetPot";
private static async Task<IReadOnlyList<AuditEntry>> ReadEntriesAsync(SqliteCommand cmd, CancellationToken ct)
{
@@ -309,7 +310,8 @@ public sealed class SqliteAuditRepository : IAuditRepository
DurationMs = r.GetInt64(13),
ResultRef = r.GetString(14),
OccurredAt = Parse(r.GetString(15)),
CallerRunId = r.IsDBNull(16) ? null : r.GetString(16)
CallerRunId = r.IsDBNull(16) ? null : r.GetString(16),
BudgetPot = r.IsDBNull(17) ? null : r.GetString(17)
};
private static string Format(DateTime value) => value.ToUniversalTime().ToString("O");
+50 -7
View File
@@ -54,6 +54,9 @@ public sealed class BudgetGuard
private readonly Dictionary<string, RunEstimate> _reservedByAgent = new(StringComparer.OrdinalIgnoreCase);
private RunEstimate _reservedInstance;
/// <summary>#717: What runs in flight have reserved of each special budget.</summary>
private readonly Dictionary<string, RunEstimate> _reservedByPot = new(StringComparer.OrdinalIgnoreCase);
public BudgetGuard(IUsageRepository usage, Func<DateTime>? now = null)
{
_usage = usage;
@@ -109,7 +112,7 @@ public sealed class BudgetGuard
_reservedInstance += estimate;
}
return (status, new Reservation(this, agent.AgentId, estimate));
return (status, new Reservation(() => Release(agent.AgentId, estimate)));
}
finally
{
@@ -117,9 +120,49 @@ public sealed class BudgetGuard
}
}
/// <summary>
/// #717: Reserves a run on a special budget instead of the daily limits. Refused once what
/// the pot's runs used, plus what runs in flight reserved, has reached the amount — the
/// same rule as for the daily limit: a run is not refused for its own estimate.
/// </summary>
public async Task<IDisposable?> ReservePotAsync(SpecialBudget pot, decimal spent, string agentId, CancellationToken ct)
{
var estimate = await EstimateRunAsync(agentId, ct);
await _reserveGate.WaitAsync(ct);
try
{
lock (_reservedLock)
{
var reserved = _reservedByPot.GetValueOrDefault(pot.TaskId);
if (spent + reserved.CostUsd >= pot.AmountUsd)
return null;
_reservedByPot[pot.TaskId] = reserved + estimate;
}
return new Reservation(() => ReleasePot(pot.TaskId, estimate));
}
finally
{
_reserveGate.Release();
}
}
private void ReleasePot(string potId, RunEstimate estimate)
{
lock (_reservedLock)
{
var left = _reservedByPot.GetValueOrDefault(potId) - estimate;
if (left.CostUsd <= 0 && left.Tokens <= 0)
_reservedByPot.Remove(potId);
else
_reservedByPot[potId] = left;
}
}
/// <summary>Today's usage of an agent, or with an empty id of the whole instance.</summary>
public Task<DailyUsage> GetUsedTodayAsync(string agentId, CancellationToken ct)
=> _usage.GetDailyAsync(DateOnly.FromDateTime(_now()), agentId, ct);
=> _usage.GetDailyForBudgetAsync(DateOnly.FromDateTime(_now()), agentId, ct);
/// <summary>What is currently reserved by runs in flight — for the agent, or with an
/// empty id for the whole instance.</summary>
@@ -159,14 +202,14 @@ public sealed class BudgetGuard
}
}
private sealed class Reservation(BudgetGuard guard, string agentId, RunEstimate estimate) : IDisposable
private sealed class Reservation(Action release) : IDisposable
{
private int _disposed;
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)
guard.Release(agentId, estimate);
release();
}
}
@@ -180,7 +223,7 @@ public sealed class BudgetGuard
var agentBudget = agent.Budget;
if (agentBudget.HasAnyLimit)
{
var used = await _usage.GetDailyAsync(today, agent.AgentId, ct);
var used = await _usage.GetDailyForBudgetAsync(today, agent.AgentId, ct);
if (Exceeds(agentBudget.DailyCostUsd, used.CostUsd + agentReserved.CostUsd))
{
@@ -203,7 +246,7 @@ public sealed class BudgetGuard
// Instanzgrenze — gilt für alle Agenten zusammen
if (instanceBudget.HasAnyLimit)
{
var used = await _usage.GetDailyAsync(today, agentId: "", ct);
var used = await _usage.GetDailyForBudgetAsync(today, agentId: "", ct);
if (Exceeds(instanceBudget.DailyCostUsd, used.CostUsd + instanceReserved.CostUsd))
{
@@ -232,7 +275,7 @@ public sealed class BudgetGuard
if (agent.Budget.DailyCostUsd <= 0)
return null;
var used = await _usage.GetDailyAsync(DateOnly.FromDateTime(_now()), agent.AgentId, ct);
var used = await _usage.GetDailyForBudgetAsync(DateOnly.FromDateTime(_now()), agent.AgentId, ct);
return Math.Max(0m, agent.Budget.DailyCostUsd - used.CostUsd);
}
+54 -11
View File
@@ -22,7 +22,7 @@ namespace ClawdDotNet.Core.Budget;
/// then only the margin keeps a reserve. That errs towards letting late work through;
/// the budget check at the start of the run still refuses once the limit is reached.</para>
/// </summary>
public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook, IFillGate
{
/// <summary>Share of each limit kept free as a reserve for surprises.</summary>
public const decimal SafetyShare = 0.10m;
@@ -31,20 +31,55 @@ public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
private readonly Func<IReadOnlyList<AgentConfig>> _agents;
private readonly Func<InstanceBudget> _instanceBudget;
private readonly TimeProvider _clock;
private readonly TaskCostEstimator? _estimator;
private readonly Func<FillWorkSettings> _fillSettings;
public BudgetHeadroom(
BudgetGuard guard,
Func<IReadOnlyList<AgentConfig>> agents,
Func<InstanceBudget> instanceBudget,
TimeProvider? clock = null)
TimeProvider? clock = null,
TaskCostEstimator? estimator = null,
Func<FillWorkSettings>? fillSettings = null)
{
_guard = guard;
_agents = agents;
_instanceBudget = instanceBudget;
_clock = clock ?? TimeProvider.System;
_estimator = estimator;
_fillSettings = fillSettings ?? (() => new FillWorkSettings());
}
public async Task<bool> HasRoomAsync(
public Task<bool> HasRoomAsync(
TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
=> HasRoomCoreAsync(lateTask, runnable, nowUtc, ct);
/// <summary>
/// #717: Fill work runs in the same headroom as late work — after it, since late work was
/// due and fill work was not. Two things are added: it prefers the evening, when the
/// forecast for the rest of the day is nearly a count, and it is measured with its own
/// estimate, not the agent's average run — fill work is by definition the large kind.
/// </summary>
public async Task<FillVerdict> CheckAsync(
TaskItem fillTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
{
var agents = _agents();
var agent = FindAgent(agents, fillTask);
// Without a limit there is no unused budget to wait for — the work just runs.
if (agent is null || (!agent.Budget.HasAnyLimit && !_instanceBudget().HasAnyLimit))
return FillVerdict.Run;
var settings = _fillSettings();
var nearDeadline = fillTask.FillBy is { } deadline && deadline - nowUtc <= settings.WarnBeforeDeadline;
var localHour = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc), _clock.LocalTimeZone).Hour;
if (!nearDeadline && localHour < Math.Clamp(settings.FromHour, 0, 24))
return FillVerdict.NotYet;
return await HasRoomCoreAsync(fillTask, runnable, nowUtc, ct) ? FillVerdict.Run : FillVerdict.NoRoom;
}
private async Task<bool> HasRoomCoreAsync(
TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
{
var agents = _agents();
@@ -66,17 +101,25 @@ public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
return estimate;
}
var ownEstimate = await EstimateFor(agent.AgentId);
var agentEstimate = await EstimateFor(agent.AgentId);
// Regular work of the rest of the day: everything scheduled except late work
// itself — that is what has to wait for headroom, not what headroom is kept for.
var regular = runnable.Where(t => t.Id != lateTask.Id && !IsLate(t)).ToList();
// #717: The task's own estimate where there is one — measured, from its siblings, or
// stated — instead of the agent's average run.
var ownEstimate = _estimator is null ? agentEstimate : (await _estimator.EstimateAsync(lateTask, ct)) switch
{
{ Source: EstimateSource.None } => agentEstimate,
var estimate => estimate.Run
};
// Regular work of the rest of the day: everything scheduled except late work and
// fill work — that is what has to wait for headroom, not what headroom is kept for.
var regular = runnable.Where(t => t.Id != lateTask.Id && !IsLate(t) && !t.IsFillWork).ToList();
if (agent.Budget.HasAnyLimit)
{
var demand = default(RunEstimate);
foreach (var task in regular.Where(t => IsOf(t, agent.AgentId)))
demand += Times(ownEstimate, TaskSchedule.OccurrencesBetween(task, nowUtc, endOfDay));
demand += Times(agentEstimate, TaskSchedule.OccurrencesBetween(task, nowUtc, endOfDay));
var used = await _guard.GetUsedTodayAsync(agent.AgentId, ct);
var reserved = _guard.Reserved(agent.AgentId);
@@ -111,8 +154,8 @@ public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
/// <summary>
/// #716: Walks through the rest of today's schedule in time order and finds, per agent
/// with a limit and for the instance, the first run the budget would no longer cover.
/// Late work is left out: it only ever runs in headroom, so it cannot be what makes the
/// day's own runs fail.
/// Late work and fill work (#717) are left out: they only ever run in headroom, so they
/// cannot be what makes the day's own runs fail.
/// </summary>
public async Task<IReadOnlyList<BudgetShortfall>> ShortfallsAsync(
IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
@@ -124,7 +167,7 @@ public sealed class BudgetHeadroom : ILateRunGate, IBudgetOutlook
// Every planned run until midnight, with whose it is.
var planned = new List<(DateTime At, string AgentId)>();
foreach (var task in runnable.Where(t => !IsLate(t)))
foreach (var task in runnable.Where(t => !IsLate(t) && !t.IsFillWork))
{
var agent = FindAgent(agents, task);
if (agent is null)
@@ -0,0 +1,53 @@
using System.Globalization;
using System.Text;
using ClawdDotNet.Core.Tasks;
namespace ClawdDotNet.Core.Budget;
/// <summary>
/// #717: The question to a human about fill work that has found no room before its deadline.
/// Only a human can answer it — raise the budget or move the deadline — so it goes on the
/// board as a human's task, below the fill work it is about.
/// </summary>
public static class FillWorkNotice
{
public static TaskItem QuestionFor(TaskItem fill, TaskEstimate estimate)
{
var body = new StringBuilder();
body.AppendLine($"Fill work {fill.Id} (\"{fill.Title}\") is due by {fill.FillBy:yyyy-MM-dd HH:mm} UTC " +
"and has not found room in the budget so far.");
body.AppendLine();
body.AppendLine($"Assigned to: {fill.Assignee}");
body.AppendLine($"Expected cost per run: {Describe(estimate)}");
body.AppendLine();
body.AppendLine("Fill work only runs on budget the rest of the day leaves unused. Please decide:");
body.AppendLine("- raise the agent's daily budget, or");
body.AppendLine($"- move the deadline (fill_by in {fill.Id}), or");
body.AppendLine("- leave it: at the deadline it runs as regular work, ahead of other late work.");
return new TaskItem
{
Title = $"Fill work {fill.Id} finds no room before its deadline",
Body = body.ToString().TrimEnd(),
Assignee = TaskAssignee.Human,
Type = TaskItemType.HumanInput,
Priority = Math.Clamp(fill.Priority + 1, 1, 5),
Status = TaskItemStatus.Todo,
ParentId = fill.Id
};
}
/// <summary>"0.80 USD (measured over 3 runs)" — and a guess says it is one.</summary>
public static string Describe(TaskEstimate estimate)
{
var usd = estimate.Run.CostUsd.ToString("0.00##", CultureInfo.InvariantCulture) + " USD";
return estimate.Source switch
{
EstimateSource.Measured => $"{usd} (measured over {estimate.Samples} run(s))",
EstimateSource.Siblings => $"{usd} (measured on {estimate.Samples} run(s) of its sibling tasks)",
EstimateSource.Stated => $"{usd} (estimated by its author, not measured)",
EstimateSource.AgentAverage => $"{usd} (the agent's average run, not measured on this task)",
_ => "unknown - nothing measured and no estimate given"
};
}
}
@@ -0,0 +1,36 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Budget;
/// <summary>
/// #717: Fill work and large tasks, instance-wide. Budgets stay at the agent (concept 8a);
/// these only say when unused budget is filled and when a task counts as large.
/// </summary>
public sealed class FillWorkSettings
{
/// <summary>
/// Local hour from which fill work may start. Late in the day the forecast for the rest of
/// it is least uncertain — at 9 in the morning "there is room" is a guess, at 8 in the
/// evening it is nearly a count. 0 lets fill work start any time there is room.
/// </summary>
[JsonPropertyName("fromHour")]
public int FromHour { get; set; } = 18;
/// <summary>
/// How many hours before its deadline fill work that found no room is taken to a human —
/// raise the budget or move the deadline. Fill work never overruns its deadline silently.
/// Within this window it also no longer waits for the evening.
/// </summary>
[JsonPropertyName("warnHoursBeforeDeadline")]
public int WarnHoursBeforeDeadline { get; set; } = 24;
/// <summary>
/// Share of the agent's daily cost limit above which a task counts as large: it is not
/// simply started, the agent proposes to split it or asks for a special budget.
/// </summary>
[JsonPropertyName("largeTaskShare")]
public decimal LargeTaskShare { get; set; } = 0.5m;
[JsonIgnore]
public TimeSpan WarnBeforeDeadline => TimeSpan.FromHours(Math.Max(0, WarnHoursBeforeDeadline));
}
@@ -0,0 +1,64 @@
using System.Globalization;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Tasks;
namespace ClawdDotNet.Core.Budget;
/// <summary>
/// #717: A task whose estimate exceeds a large share of the agent's daily cost limit (default
/// half) is not simply started. Its run plans instead: split it into parts, or ask a human for
/// a special budget. Which way, the agent proposes; a special budget is always a human's call.
///
/// <para><b>Only on an estimate of this task.</b> Measured runs, its siblings' runs, or its
/// author's estimate. The agent's average run says nothing about one task — were it enough, an
/// agent with expensive runs would have every task stopped for planning.</para>
///
/// <para>Not for a task already split (it has parts), nor for one in a funded tree, nor for a
/// recurring one — its runs are the day's regular work and have their own limit.</para>
/// </summary>
public sealed class LargeTaskCheck(
TaskCostEstimator estimator,
ITaskRepository tasks,
ISpecialBudgetRepository? pots,
Func<IReadOnlyList<AgentConfig>> agents,
Func<FillWorkSettings> settings) : ILargeTaskCheck
{
public async Task<string?> PlanningInstructionAsync(TaskItem task, CancellationToken ct)
{
var agentId = TaskAssignee.AgentId(task.Assignee);
var agent = agents().FirstOrDefault(a => string.Equals(a.AgentId, agentId, StringComparison.OrdinalIgnoreCase));
var share = settings().LargeTaskShare;
if (agent is not { Budget.DailyCostUsd: > 0 } || share <= 0)
return null;
if (await IsFundedAsync(task, ct) || (await tasks.ListChildrenAsync(task.Id, ct)).Count > 0)
return null;
var estimate = await estimator.EstimateAsync(task, ct);
if (estimate.Source is EstimateSource.AgentAverage or EstimateSource.None)
return null;
var threshold = agent.Budget.DailyCostUsd * share;
return estimate.Run.CostUsd > threshold
? Instruction(task, estimate, agent.Budget.DailyCostUsd, share)
: null;
}
public async Task<bool> IsFundedAsync(TaskItem task, CancellationToken ct)
=> pots is not null && await SpecialBudgets.FindAsync(task.Id, tasks, pots, ct) is not null;
/// <summary>What the planning run is told — ahead of the task itself.</summary>
public static string Instruction(TaskItem task, TaskEstimate estimate, decimal dailyLimit, decimal share)
{
var limit = dailyLimit.ToString("0.00", CultureInfo.InvariantCulture);
var percent = (share * 100).ToString("0", CultureInfo.InvariantCulture);
return $"""
[Planning run — from the system, not from the task's author]
Task {task.Id} is large: one run is expected to cost {FillWorkNotice.Describe(estimate)}, more than {percent} % of your daily budget of {limit} USD.
Do not start the work in this run. Propose one of two ways:
1. Split it: create parts with the taskboard (task_create, parent='{task.Id}'), each able to run on its own and small enough for a day. Parts that are not urgent can be fill work (fill_by). Task {task.Id} waits until its parts are done and then runs once more to bring them together.
2. Ask for a special budget: Escalation, action request_budget, with task='{task.Id}', amount_usd and reasoning. A human decides. The budget is for this task and its parts only.
End with one or two sentences: which way, and why.
""";
}
}
@@ -0,0 +1,133 @@
using System.Globalization;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
namespace ClawdDotNet.Core.Budget;
/// <summary>
/// #717: A special budget — an amount for exactly one task and everything below it, approved
/// by a human.
///
/// <para><b>The one exception to "budgets stay at the agent" (concept 8a).</b> It is a pot of
/// its own: runs in the task tree draw on it instead of the agent's daily limit, so a large
/// job neither eats the day's regular work nor fails at the daily limit halfway. It does not
/// raise the daily limit — work outside the tree never sees it — and it is never silent: it
/// exists only through a human's approval (staging), and its runs are marked in the cost
/// analysis. Once it is used up, the tree's runs count against the daily budget again, like
/// any other work.</para>
/// </summary>
public sealed record SpecialBudget
{
/// <summary>The task at the top of the tree the pot is for.</summary>
public string TaskId { get; init; } = "";
public decimal AmountUsd { get; init; }
public string Reason { get; init; } = "";
/// <summary>The agent that asked for it.</summary>
public string RequestedBy { get; init; } = "";
public DateTime ApprovedAt { get; init; }
}
/// <summary>#717: Where special budgets are kept, and what they have used so far.</summary>
public interface ISpecialBudgetRepository
{
Task<SpecialBudget?> GetAsync(string taskId, CancellationToken ct);
/// <summary>Sets or replaces the pot of a task — a second approval replaces the amount.</summary>
Task SetAsync(SpecialBudget budget, CancellationToken ct);
Task<IReadOnlyList<SpecialBudget>> ListAsync(CancellationToken ct);
/// <summary>What runs have drawn on the pot, from the usage records marked with it.</summary>
Task<decimal> SpentAsync(string taskId, CancellationToken ct);
}
public sealed class SqliteSpecialBudgetRepository(SqliteStorage storage) : ISpecialBudgetRepository
{
public async Task<SpecialBudget?> GetAsync(string taskId, CancellationToken ct)
{
await using var conn = await storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT TaskId, AmountUsd, Reason, RequestedBy, ApprovedAt FROM SpecialBudgets WHERE TaskId = @id COLLATE NOCASE";
cmd.Parameters.AddWithValue("@id", taskId.Trim());
await using var reader = await cmd.ExecuteReaderAsync(ct);
return await reader.ReadAsync(ct) ? Read(reader) : null;
}
public Task SetAsync(SpecialBudget budget, CancellationToken ct)
=> storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO SpecialBudgets (TaskId, AmountUsd, Reason, RequestedBy, ApprovedAt)
VALUES (@id, @amount, @reason, @by, @at)
ON CONFLICT(TaskId) DO UPDATE SET
AmountUsd = excluded.AmountUsd, Reason = excluded.Reason,
RequestedBy = excluded.RequestedBy, ApprovedAt = excluded.ApprovedAt
""";
cmd.Parameters.AddWithValue("@id", budget.TaskId.Trim());
cmd.Parameters.AddWithValue("@amount", budget.AmountUsd.ToString(CultureInfo.InvariantCulture));
cmd.Parameters.AddWithValue("@reason", budget.Reason);
cmd.Parameters.AddWithValue("@by", budget.RequestedBy);
cmd.Parameters.AddWithValue("@at", budget.ApprovedAt.ToUniversalTime().ToString("O"));
await cmd.ExecuteNonQueryAsync(ct);
}, ct);
public async Task<IReadOnlyList<SpecialBudget>> ListAsync(CancellationToken ct)
{
await using var conn = await storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT TaskId, AmountUsd, Reason, RequestedBy, ApprovedAt FROM SpecialBudgets ORDER BY ApprovedAt DESC";
var result = new List<SpecialBudget>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
result.Add(Read(reader));
return result;
}
public async Task<decimal> SpentAsync(string taskId, CancellationToken ct)
{
await using var conn = await storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
// Amounts are text (exact decimals); summed here, not in SQL.
cmd.CommandText = "SELECT CostUsd FROM RunUsage WHERE BudgetPot = @id COLLATE NOCASE";
cmd.Parameters.AddWithValue("@id", taskId.Trim());
var spent = 0m;
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
spent += decimal.TryParse(reader.GetString(0), NumberStyles.Float, CultureInfo.InvariantCulture, out var d) ? d : 0m;
return spent;
}
private static SpecialBudget Read(Microsoft.Data.Sqlite.SqliteDataReader r) => new()
{
TaskId = r.GetString(0),
AmountUsd = decimal.TryParse(r.GetString(1), NumberStyles.Float, CultureInfo.InvariantCulture, out var d) ? d : 0m,
Reason = r.GetString(2),
RequestedBy = r.GetString(3),
ApprovedAt = DateTime.TryParse(r.GetString(4), null, DateTimeStyles.RoundtripKind, out var at) ? at : DateTime.MinValue
};
}
/// <summary>#717: Finding the pot a task draws on — its own, or the nearest one above it.</summary>
public static class SpecialBudgets
{
/// <summary>A tree deeper than this is a mistake, not a plan; the walk stops there.</summary>
private const int MaxDepth = 32;
public static async Task<SpecialBudget?> FindAsync(
string taskId, ITaskRepository tasks, ISpecialBudgetRepository pots, CancellationToken ct)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var current = taskId.Trim();
for (var depth = 0; depth < MaxDepth && current.Length > 0 && seen.Add(current); depth++)
{
if (await pots.GetAsync(current, ct) is { } pot)
return pot;
current = (await tasks.GetAsync(current, ct))?.ParentId?.Trim() ?? "";
}
return null;
}
}
@@ -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;
+66 -14
View File
@@ -54,6 +54,12 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>#689: Escalations, conflicts and decisions; set by the host. Tools reach it through their context.</summary>
public Cases.ICaseDesk? CaseDesk { get; set; }
/// <summary>
/// #717: Special budgets. A run on a task in a funded tree draws on the pot instead of the
/// daily limits; without this, every run counts against the day.
/// </summary>
public Budget.ISpecialBudgetRepository? SpecialBudgets { get; set; }
private Organisation.OrgChart CurrentOrganisation()
=> OrganisationProvider?.Invoke() ?? new Organisation.OrgChart([], _agentConfigProvider?.Invoke() ?? []);
@@ -192,7 +198,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// Vor der ersten Anfrage prüfen — ein erschöpftes Budget soll gar nichts kosten.
// #716: Die Reservierung gilt, bis der Verbrauch erfasst ist (Ende der Methode).
var (denied, reservation) = await CheckBudgetAsync(agentConfig, source ?? AuditSource.Direct, taskId, externalCt);
var (denied, reservation, pot) = await CheckBudgetAsync(agentConfig, source ?? AuditSource.Direct, taskId, externalCt);
if (denied is not null)
return denied;
using var budgetReservation = reservation;
@@ -205,8 +211,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var recorder = CreateRecorder(agentConfig);
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId, chain.CallerRunId);
await RecordUsageAsync(agentConfig, result, pot);
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId, chain.CallerRunId, pot);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
return result;
}
@@ -478,7 +484,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
if (RefuseWhileShuttingDown(agentConfig) is { } zu)
return zu;
var (denied, reservation) = await CheckBudgetAsync(agentConfig, source, taskId, externalCt);
var (denied, reservation, pot) = await CheckBudgetAsync(agentConfig, source, taskId, externalCt);
if (denied is not null)
return denied;
using var budgetReservation = reservation;
@@ -528,8 +534,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var recorder = CreateRecorder(agentConfig);
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source, taskId, chain.CallerRunId);
await RecordUsageAsync(agentConfig, result, pot);
await RecordReceiptAsync(runId, agentConfig, result, source, taskId, chain.CallerRunId, pot);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
return result;
}
@@ -1693,6 +1699,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
Organisation = CurrentOrganisation(),
Trust = ReportingLine.TrustOf(agentConfig, _toolRegistry.Get),
Cases = CaseDesk,
SpecialBudgets = SpecialBudgets,
// #697: Where something an agent hands on came from — known to the engine, not claimed by the agent.
RunId = runId,
RunTaskId = runId is null ? null : Activity.Find(runId)?.TaskId
@@ -1748,16 +1755,21 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>
/// Prüft das Tagesbudget. Gibt ein Ergebnis zurück, wenn der Lauf nicht stattfinden
/// darf — sonst null und, bei gesetzter Grenze, die Reservierung des Laufs (#716).
/// #717: Liegt die Aufgabe in einem Baum mit Sonderbudget, zieht der Lauf den Topf statt
/// der Tagesgrenzen; dessen Kennung kommt als <c>Pot</c> zurück.
/// </summary>
private async Task<(AgentRunResult? Denied, IDisposable? Reservation)> CheckBudgetAsync(
private async Task<(AgentRunResult? Denied, IDisposable? Reservation, string? Pot)> CheckBudgetAsync(
AgentConfig agentConfig, string? source, string? taskId, CancellationToken ct)
{
if (_budgetGuard is null)
return (null, null);
return (null, null, null);
if (await ReservePotAsync(agentConfig, taskId, ct) is { } potRun)
return (null, potRun.Reservation, potRun.Pot);
var (status, reservation) = await _budgetGuard.ReserveAsync(agentConfig, InstanceBudget, ct);
if (status.IsAllowed)
return (null, reservation);
return (null, reservation, null);
// #608: Ist ein Budget erschoepft, lehnt es jeden weiteren Lauf des Tages ab. Einmal je
// Agent, Grenze und Tag als Warnung genuegt - danach steht jede Ablehnung als Beleg da.
@@ -1785,9 +1797,47 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
await RecordReceiptAsync(Guid.NewGuid().ToString("N"), agentConfig, result, source, taskId);
OnRunCompleted?.Invoke(agentConfig.Model, result);
return (result, null);
return (result, null, null);
}
/// <summary>
/// #717: The special budget of the task's tree, if there is one and it has something left.
/// A used-up pot is not a refusal — the tree's runs count against the day again, like any
/// other work, and that is said once in the log.
/// </summary>
private async Task<(IDisposable Reservation, string Pot)?> ReservePotAsync(
AgentConfig agentConfig, string? taskId, CancellationToken ct)
{
if (_budgetGuard is null || SpecialBudgets is not { } pots || _taskRepository is null || string.IsNullOrWhiteSpace(taskId))
return null;
try
{
if (await Budget.SpecialBudgets.FindAsync(taskId, _taskRepository, pots, ct) is not { } pot)
return null;
var spent = await pots.SpentAsync(pot.TaskId, ct);
if (await _budgetGuard.ReservePotAsync(pot, spent, agentConfig.AgentId, ct) is { } reservation)
return (reservation, pot.TaskId);
if (_potUsedUp.TryAdd(pot.TaskId, 0))
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Budget").LogWarning(
"Special budget of {TaskId} is used up ({Spent:F2} of {Amount:F2} USD) — runs in its tree count against the daily budget again",
pot.TaskId, spent, pot.AmountUsd);
return null;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Without the pot the run is checked like any other — never unchecked.
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Budget")
.LogWarning(ex, "Special budget lookup failed for task {TaskId}", taskId);
return null;
}
}
/// <summary>#717: Pots already reported as used up — said once, not at every run.</summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _potUsedUp = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Welche Budgetgrenze je Agent zuletzt an welchem Tag als Warnung gemeldet wurde.</summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateOnly> _budgetGewarnt = new();
@@ -1795,7 +1845,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// Schreibt den Verbrauch eines Laufs fort. Fehler hierbei dürfen den Lauf nicht
/// nachträglich scheitern lassen — die eigentliche Arbeit ist bereits getan.
/// </summary>
private async Task RecordUsageAsync(AgentConfig agentConfig, AgentRunResult result)
private async Task RecordUsageAsync(AgentConfig agentConfig, AgentRunResult result, string? budgetPot = null)
{
if (_usageRepository is null)
return;
@@ -1822,7 +1872,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
Status = result.Status.ToString(),
StepCount = result.StepCount,
DurationMs = (long)result.Duration.TotalMilliseconds,
OccurredAt = DateTime.Now
OccurredAt = DateTime.Now,
BudgetPot = budgetPot ?? ""
}, CancellationToken.None);
}
catch (Exception ex)
@@ -1920,7 +1971,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
private async Task RecordReceiptAsync(
string runId, AgentConfig agentConfig, AgentRunResult result, string? source, string? taskId,
string? callerRunId = null)
string? callerRunId = null, string? budgetPot = null)
{
if (_auditRepository is null)
return;
@@ -1951,7 +2002,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
DurationMs = (long)result.Duration.TotalMilliseconds,
ResultRef = Cap(result.FinalMessage ?? "", 500),
OccurredAt = DateTime.Now,
CallerRunId = callerRunId
CallerRunId = callerRunId,
BudgetPot = budgetPot
}, CancellationToken.None);
}
catch (Exception ex)
+10 -3
View File
@@ -93,12 +93,17 @@ public readonly record struct Freigabeeinstufung(StagingDecision Entscheidung, s
/// </summary>
public static class FreigabeRegeln
{
/// <summary>Was einen Agenten verändert. Bleibt ausnahmslos freigabepflichtig.</summary>
/// <summary>
/// Was einen Agenten verändert — und #717 ein Sonderbudget: Geld über die Tagesgrenze
/// hinaus entscheidet immer ein Mensch. Bleibt ausnahmslos freigabepflichtig, auch gegen
/// Werkzeugkonfiguration und Dauerfreigabe.
/// </summary>
public static IReadOnlySet<string> ImmerFreigabe { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"AgentEditor.update_identity",
"AgentEditor.update_soul",
"AgentEditor.create_agent"
"AgentEditor.create_agent",
"Escalation.request_budget"
};
public static bool IstImmerFreigabe(string tool, string? operation)
@@ -109,7 +114,9 @@ public static class FreigabeRegeln
StagingDecision? werkzeugVorgabe, StagingPolicy policy, bool dauerfreigabe = false)
{
if (IstImmerFreigabe(tool, operation))
return new(StagingDecision.Approve, "Änderungen an Agenten brauchen immer eine Freigabe.");
return new(StagingDecision.Approve, string.Equals(tool, "Escalation", StringComparison.OrdinalIgnoreCase)
? "Ein Sonderbudget entscheidet immer ein Mensch."
: "Änderungen an Agenten brauchen immer eine Freigabe.");
if (string.IsNullOrWhiteSpace(operation))
return Nach(policy.Decide(tool, null), "der Standardregel");
@@ -373,6 +373,15 @@ public sealed class SqliteStorage
DecidedAt TEXT NOT NULL,
Json TEXT NOT NULL
);
-- #717: Special budgets — one pot per task tree, approved by a human.
CREATE TABLE IF NOT EXISTS SpecialBudgets (
TaskId TEXT PRIMARY KEY COLLATE NOCASE,
AmountUsd TEXT NOT NULL,
Reason TEXT NOT NULL DEFAULT '',
RequestedBy TEXT NOT NULL DEFAULT '',
ApprovedAt TEXT NOT NULL
);
""";
cmd.ExecuteNonQuery();
@@ -412,6 +421,19 @@ 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");
// #717: A large task got its planning run — split or special budget, the agent proposed.
SpalteErgaenzen(connection, "Tasks", "LargePlannedAt", "TEXT NULL");
// #717: Runs that drew on a special budget — outside the daily limits, visible apart.
SpalteErgaenzen(connection, "RunUsage", "BudgetPot", "TEXT NOT NULL DEFAULT ''");
SpalteErgaenzen(connection, "RunReceipts", "BudgetPot", "TEXT NULL");
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
using var index = connection.CreateCommand();
index.CommandText = """
@@ -53,7 +53,14 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
_clock = clock ?? TimeProvider.System;
}
public async Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
public Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
=> DispatchAsync(task, null, ct);
/// <summary>
/// #717: With an instruction from the code ahead of the task — for a large task, that the
/// run plans instead of starting the work.
/// </summary>
public async Task<TaskLauf> DispatchAsync(TaskItem task, string? instruction, CancellationToken ct)
{
// Poll-Tasks (tool_job) ticken ein Tool statt einen Agenten direkt zu starten.
if (task.Type == TaskItemType.ToolJob)
@@ -61,6 +68,8 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
var agents = _agents();
var message = BuildMessage(task, await WhyAsync(task, ct));
if (!string.IsNullOrWhiteSpace(instruction))
message = instruction.Trim() + "\n\n---\n\n" + message;
var kind = TaskAssignee.KindOf(task.Assignee);
AgentRunResult result;
@@ -94,6 +94,15 @@ 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>#717: Records that a large task had its planning run.</summary>
Task<bool> MarkLargePlannedAsync(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 +117,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,28 @@ 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<bool> MarkLargePlannedAsync(string id, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE Tasks SET LargePlannedAt = @now, UpdatedAt = @now WHERE Id = @id";
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 +344,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 +373,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, LargePlannedAt";
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
{
@@ -361,6 +400,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 +458,12 @@ 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)),
LargePlannedAt = r.IsDBNull(33) ? null : Parse(r.GetString(33))
};
}
@@ -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();
}
+31
View File
@@ -252,6 +252,37 @@ 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: When a large task had its planning run — split or special budget, the agent
/// proposed. Planned once; afterwards it runs like any other task.
/// </summary>
public DateTime? LargePlannedAt { 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;
+270 -11
View File
@@ -33,6 +33,26 @@ public interface ITaskDispatcher
{
/// <summary>Führt die Aufgabe aus und meldet, wie der Lauf ausging.</summary>
Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct);
/// <summary>
/// #717: Runs the task with an instruction from the code ahead of it. Without an
/// implementation the instruction is dropped and the task runs as usual.
/// </summary>
Task<TaskLauf> DispatchAsync(TaskItem task, string? instruction, CancellationToken ct)
=> DispatchAsync(task, ct);
}
/// <summary>
/// #717: Whether a task is too large to simply start — its estimate is a large share of the
/// agent's daily budget. Then its run plans instead: split it, or ask for a special budget.
/// </summary>
public interface ILargeTaskCheck
{
/// <summary>The instruction for the planning run, or <c>null</c> if the task can simply run.</summary>
Task<string?> PlanningInstructionAsync(TaskItem task, CancellationToken ct);
/// <summary>Whether the task's tree has a special budget — then, after planning, it runs.</summary>
Task<bool> IsFundedAsync(TaskItem task, CancellationToken ct);
}
/// <summary>
@@ -53,6 +73,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 +182,26 @@ 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: Whether a task is too large to simply start.</summary>
private readonly ILargeTaskCheck? _largeCheck;
/// <summary>#717: Leaves a note on a task — why the scanner holds it.</summary>
private readonly Func<string, string, CancellationToken, Task>? _noteOnTask;
/// <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 +228,19 @@ 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,
ILargeTaskCheck? largeTaskCheck = null,
Func<string, string, CancellationToken, Task>? noteOnTask = null)
{
_largeCheck = largeTaskCheck;
_noteOnTask = noteOnTask;
_treeWatch = treeWatch;
_fillGate = fillGate;
_askAboutFill = askAboutFill;
_fillWarnBefore = fillWarnBefore ?? (() => TimeSpan.FromHours(24));
_repo = repo;
_dispatcher = dispatcher;
_market = market ?? new AlwaysOpenMarketCalendar();
@@ -293,7 +366,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 +419,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 +465,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 +542,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.
@@ -496,10 +646,15 @@ public sealed class TaskScanner : IAsyncDisposable
private async Task ProcessCoreAsync(TaskItem task, string token, CancellationToken ct)
{
// #717: A large task is not simply started — its run plans first.
var planning = await PlanningInstructionAsync(task, ct);
TaskLauf lauf;
try
{
lauf = await _dispatcher.DispatchAsync(task, ct);
lauf = planning is null
? await _dispatcher.DispatchAsync(task, ct)
: await _dispatcher.DispatchAsync(task, planning, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
@@ -536,6 +691,12 @@ public sealed class TaskScanner : IAsyncDisposable
_zurueckgestellt.TryRemove(task.Id, out _);
if (planning is not null)
{
await SettlePlanningAsync(task, token, lauf, now, ct);
return;
}
// Endstatus:
// - Fehlschlag → zurück auf todo (der Marker steht schon beim Claim, derselbe Termin
// wird nicht wiederholt).
@@ -562,9 +723,99 @@ public sealed class TaskScanner : IAsyncDisposable
await _repo.ClearBudgetMissAsync(task.Id, now, ct);
if (ok && final == TaskItemStatus.Done)
{
await UnblockDependentsAsync(task.Id, now, ct);
await ResumeLargeParentAsync(task, now, ct);
}
}
/// <summary>
/// #717: The planning instruction for a large one-time task that has not been planned yet.
/// A failing check costs the check, not the run.
/// </summary>
private async Task<string?> PlanningInstructionAsync(TaskItem task, CancellationToken ct)
{
if (_largeCheck is null || task.IsRecurring || task.LargePlannedAt is not null || task.Type == TaskItemType.ToolJob)
return null;
try
{
return await _largeCheck.PlanningInstructionAsync(task, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Large-task check failed for {TaskId} — it runs as usual", task.Id);
return null;
}
}
/// <summary>
/// #717: After the planning run of a large task. Split into parts → it waits for them.
/// A special budget approved meanwhile → it runs. Otherwise it waits, blocked, for the
/// human's decision on the budget — never silently: the task says why.
///
/// <para>The occurrence is given back first: the planning run did not do the work, and a
/// one-time task whose marker is used up would never run again.</para>
/// </summary>
private async Task SettlePlanningAsync(TaskItem task, string token, TaskLauf lauf, DateTime now, CancellationToken ct)
{
if (!await _repo.ReleaseClaimAsync(task.Id, token, task.LastOccurrence, now, ct))
return;
if (lauf != TaskLauf.Erledigt)
return; // the planning did not take place or failed — the next due tick plans again
await _repo.MarkLargePlannedAsync(task.Id, now, ct);
var parts = (await _repo.ListChildrenAsync(task.Id, ct)).Count(IsOpen);
string? note = null;
if (parts > 0)
{
await _repo.SetStatusAsync(task.Id, TaskItemStatus.Blocked, now, ct);
note = $"Large task: split into {parts} part(s). It runs again once they are done.";
}
else if (_largeCheck is not null && await _largeCheck.IsFundedAsync(task, ct))
{
_logger.LogInformation("Large task {TaskId} has a special budget — it runs on it", task.Id);
}
else
{
await _repo.SetStatusAsync(task.Id, TaskItemStatus.Blocked, now, ct);
note = "Large task: not started. It waits for a special budget to be approved (staging), "
+ "or for a human to split it or set it back to todo.";
}
if (note is not null)
{
_logger.LogInformation("Task {TaskId}: {Note}", task.Id, note);
if (_noteOnTask is not null)
{
try { await _noteOnTask(task.Id, note, ct); }
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Could not note on task {TaskId}", task.Id);
}
}
}
}
/// <summary>
/// #717: A part of a split large task is done. When it was the last open one, the large
/// task is taken up again — to bring the parts together.
/// </summary>
private async Task ResumeLargeParentAsync(TaskItem part, DateTime now, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(part.ParentId)
|| await _repo.GetAsync(part.ParentId, ct) is not { Status: TaskItemStatus.Blocked, LargePlannedAt: not null } parent)
return;
if ((await _repo.ListChildrenAsync(parent.Id, ct)).Any(IsOpen))
return;
await _repo.SetStatusAsync(parent.Id, TaskItemStatus.Todo, now, ct);
_logger.LogInformation("Large task {TaskId}: all parts done — it runs again to bring them together", parent.Id);
}
private static bool IsOpen(TaskItem task)
=> task.Status is not (TaskItemStatus.Done or TaskItemStatus.Canceled or TaskItemStatus.Archived);
/// <summary>
/// #716: The budget refused the run. A one-time task is carried over to the next day
/// with its occurrence intact; a recurring one follows the agent's policy — the
@@ -698,6 +949,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.
@@ -49,6 +49,9 @@ public sealed record AgentToolContext(
/// <summary>#689: The desk for escalations, conflicts and decisions. <c>null</c> outside the engine.</summary>
public Cases.ICaseDesk? Cases { get; init; }
/// <summary>#717: Special budgets — set up by an approved request_budget.</summary>
public Budget.ISpecialBudgetRepository? SpecialBudgets { get; init; }
/// <summary>#697: The run this call belongs to — for stamping where something came from.</summary>
public string? RunId { get; init; }
@@ -363,6 +363,13 @@
"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)",
"tasks.specialBudget": "Sonderbudget für diese Aufgabe und ihre Teile: {spent} von {amount} verbraucht",
"services.title": "Dienste",
"services.newService": "Neuer Dienst",
@@ -763,6 +770,9 @@
"timeline.groupBy.agent": "Agent",
"timeline.groupBy.model": "Modell",
"timeline.groupBy.source": "Ereignis",
"timeline.groupBy.budget": "Budget",
"timeline.budget.daily": "Tagesbudget",
"timeline.budget.special": "Sonderbudget {id}",
"timeline.bucket": "Je",
"timeline.bucket.hour": "Stunde",
"timeline.bucket.day": "Tag",
@@ -363,6 +363,13 @@
"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)",
"tasks.specialBudget": "Special budget for this task and its parts: {spent} of {amount} used",
"services.title": "Services",
"services.newService": "New Service",
@@ -763,6 +770,9 @@
"timeline.groupBy.agent": "Agent",
"timeline.groupBy.model": "Model",
"timeline.groupBy.source": "Event",
"timeline.groupBy.budget": "Budget",
"timeline.budget.daily": "Daily budget",
"timeline.budget.special": "Special budget {id}",
"timeline.bucket": "Per",
"timeline.bucket.hour": "Hour",
"timeline.bucket.day": "Day",
@@ -108,6 +108,9 @@ export async function rendereAufgaben() {
await ladeTaskboard();
}
/** #717: Special budgets by task id — filled by ladeTaskboard. */
let sonderbudgets = new Map();
async function ladeTaskboard() {
const container = document.getElementById("aufgaben-bereich-inhalt");
if (!container) return;
@@ -117,7 +120,12 @@ async function ladeTaskboard() {
);
try {
const alle = (await api("/tasks?limit=200")) || [];
const [alle, toepfe] = await Promise.all([
api("/tasks?limit=200").then((r) => r || []),
// #717: special budgets, shown on the task they are for; without them the board still loads.
api("/special-budgets").then((r) => r || []).catch(() => [])
]);
sonderbudgets = new Map(toepfe.map((b) => [b.taskId.toLowerCase(), b]));
const offen = alle.filter((t) => zeigeAbgeschlossene || !ABGESCHLOSSEN.includes(t.status));
const tasks = offen.filter((t) => !brettFilter || (brettFilter === "-" ? !t.board : t.board === brettFilter));
container.replaceChildren();
@@ -174,6 +182,19 @@ function brettAuswahl(tasks) {
erstelleElement("span", { text: t("tasks.board") }), auswahl);
}
/** #717: A budget or an estimate is an amount of money, not a run's cost — cents are enough. */
function betrag(usd) {
return "$" + Number(usd ?? 0).toFixed(2);
}
/**
* #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 +254,28 @@ 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(" · ")}` }));
}
// #717: A special budget is the one exception to the daily limit — never silent.
const topf = sonderbudgets.get(task.id.toLowerCase());
if (topf) {
zeilen.push(erstelleElement("p", {
klasse: "text-klein",
title: `${topf.reason} (${topf.requestedBy}, ${formatiereDatumZeit(topf.approvedAt)})`,
text: `◆ ${t("tasks.specialBudget", { spent: betrag(topf.spentUsd), amount: betrag(topf.amountUsd) })}`
}));
}
if (task.estimateUsd != null) {
zeilen.push(erstelleElement("p", {
klasse: "gedaempft text-klein",
text: t("tasks.estimate", { usd: betrag(task.estimateUsd) })
}));
}
// #716: What the budget refused, and what happens next.
const ausfall = task.budgetMiss;
if (ausfall) {
@@ -360,6 +403,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 +425,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 +444,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 +477,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 {
@@ -91,11 +91,22 @@ function buildControls(filters, bucket, groupBy, metric) {
bar.append(
group("metric", ["cost", "tokens", "runs"], metric),
group("groupBy", ["agent", "model", "source"], groupBy),
group("groupBy", ["agent", "model", "source", "budget"], groupBy),
group("bucket", ["hour", "day"], bucket));
return bar;
}
/** A group's label — sources translated, #717 budget pots named. */
function groupLabel(groupBy, group) {
if (groupBy === "source") return sourceLabel(group);
if (groupBy === "budget") {
return group.startsWith("special:")
? t("timeline.budget.special", { id: group.slice("special:".length) })
: t("timeline.budget.daily");
}
return group;
}
function bucketsBetween(from, to, bucket) {
const step = bucket === "day" ? 864e5 : 36e5;
const start = new Date(from);
@@ -172,7 +183,7 @@ function buildChart(points, events, filters, bucket, groupBy, metric) {
y -= h;
const rect = svg("rect", {
x: x + 1, y, width: barWidth - 2, height: h, fill: color(p.group), class: "balken", tabindex: 0
}, title(`${formatBucket(buckets[i], bucket)} · ${groupBy === "source" ? sourceLabel(p.group) : p.group}: ` +
}, title(`${formatBucket(buckets[i], bucket)} · ${groupLabel(groupBy, p.group)}: ` +
`${formatValue(valueOf(p), metric)} (${t("timeline.tooltipRuns", { n: p.runs })})`));
rect.addEventListener("click", () => openBucket(filters, buckets[i], step, groupBy, p.group));
chart.append(rect);
@@ -201,7 +212,7 @@ function buildChart(points, events, filters, bucket, groupBy, metric) {
const total = points.filter((p) => p.group === group).reduce((sum, p) => sum + valueOf(p), 0);
legend.append(erstelleElement("span", { klasse: "legende-eintrag" },
erstelleElement("span", { klasse: "legende-farbe", stil: { background: color(group) } }),
erstelleElement("span", { text: `${groupBy === "source" ? sourceLabel(group) : group}: ${formatValue(total, metric)}` })));
erstelleElement("span", { text: `${groupLabel(groupBy, group)}: ${formatValue(total, metric)}` })));
}
if (events.length > 0) {
legend.append(
@@ -229,6 +240,7 @@ function openBucket(filters, start, step, groupBy, group) {
delete next.bucket;
delete next.groupBy;
delete next.metric;
next[groupBy === "source" ? "source" : groupBy] = group;
// #717: There is no filter by budget pot — the runs of the bucket are shown unfiltered.
if (groupBy !== "budget") next[groupBy === "source" ? "source" : groupBy] = group;
applyFilters("/verbrauch/runs", next);
}
@@ -1,6 +1,9 @@
using System.Text;
using System.Text.Json;
using System.Globalization;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Cases;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tools;
namespace ClawdDotNet.Tools.Taskboard;
@@ -27,6 +30,7 @@ public sealed class EscalationTool : IAgentTool
- open_conflict: you disagree with an agent you cannot instruct and that cannot instruct you. It goes to the lowest place above both of you, after the other side stated its position.
- state_position: answer a request for your position or a statement.
After the deadline a human decides.
- request_budget: a special budget for one large task and its parts (task, amount_usd, reasoning). A human always decides; it does not raise your daily limit.
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
@@ -35,17 +39,18 @@ public sealed class EscalationTool : IAgentTool
"properties": {
"action": {
"type": "string",
"enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position"]
"enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget"]
},
"amount_usd": { "type": "number", "description": "request_budget: the amount for the task and all its parts, in USD." },
"subject": { "type": "string", "description": "What it is about, in a few words — like a title. escalate, open_conflict, find_decisions." },
"question": { "type": "string", "description": "escalate: the question that has to be decided." },
"tried": { "type": "string", "description": "escalate: what you tried, and what you found (or did not find)." },
"needed": { "type": "string", "description": "escalate: what you would need to get on." },
"task": { "type": "string", "description": "escalate, open_conflict: the task you are stuck on. It waits for the answer." },
"task": { "type": "string", "description": "escalate, open_conflict: the task you are stuck on. It waits for the answer. request_budget: the task the budget is for (required)." },
"force": { "type": "boolean", "description": "escalate: although a decision seems to answer it — say in 'tried' why it does not fit." },
"case": { "type": "string", "description": "The case id (c-…) for decide, handle, forward, state_position." },
"answer": { "type": "string", "description": "decide: the decision." },
"reasoning": { "type": "string", "description": "decide: why. Required — a decision without reasons cannot be applied." },
"reasoning": { "type": "string", "description": "decide: why. Required — a decision without reasons cannot be applied. request_budget: why this amount, and why it cannot be split." },
"note": { "type": "string", "description": "handle: what you did about it." },
"assessment": { "type": "string", "description": "forward: your view, added to the case." },
"with": { "type": "string", "description": "open_conflict: the agent you disagree with." },
@@ -55,8 +60,16 @@ public sealed class EscalationTool : IAgentTool
}
""").RootElement.Clone();
/// <summary>#717: A request that cannot be granted is refused before it reaches a human.</summary>
public string? Preflight(JsonElement input, AgentToolContext context)
=> Get(input, "action") == "request_budget" ? CheckBudgetRequest(input, context, out _, out _) : null;
public async Task<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
// #717: Runs only after a human approved it — the staging rule for it cannot be lifted.
if (Get(input, "action") == "request_budget")
return await SetUpBudgetAsync(input, context, ct);
if (context.Cases is not { } desk)
return ToolResult.Fail("Escalation is not available in this instance.");
@@ -125,12 +138,64 @@ public sealed class EscalationTool : IAgentTool
break;
default:
return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position.");
return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position, request_budget.");
}
return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message);
}
/// <summary>
/// #717: What a budget request needs — checked twice: before it is put to a human, and when
/// the approved call runs. The task must exist, and it must be the caller's: its assignee
/// or its author. Nobody asks for money for someone else's work.
/// </summary>
private static string? CheckBudgetRequest(JsonElement input, AgentToolContext context, out string taskId, out decimal amount)
{
taskId = (Get(input, "task") ?? "").Trim();
amount = 0;
if (context.SpecialBudgets is null || context.Tasks is null)
return "Special budgets are not available in this instance.";
if (taskId.Length == 0)
return "'task' is required — the task the budget is for.";
if (!input.TryGetProperty("amount_usd", out var a) || a.ValueKind != JsonValueKind.Number
|| !a.TryGetDecimal(out amount) || amount <= 0)
return "'amount_usd' is required and must be above 0.";
if (string.IsNullOrWhiteSpace(Get(input, "reasoning")))
return "'reasoning' is required — why this amount, and why the task cannot be split.";
var task = context.Tasks.GetAsync(taskId, CancellationToken.None).GetAwaiter().GetResult();
if (task is null)
return $"No task '{taskId}'.";
var me = context.AgentId;
if (!string.Equals(TaskAssignee.AgentId(task.Assignee), me, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(task.CreatedBy, me, StringComparison.OrdinalIgnoreCase))
return $"Task '{taskId}' is neither assigned to you nor created by you — ask for a budget for your own work.";
return null;
}
private static async Task<ToolResult> SetUpBudgetAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
if (CheckBudgetRequest(input, context, out var taskId, out var amount) is { } problem)
return ToolResult.Fail(problem);
await context.SpecialBudgets!.SetAsync(new SpecialBudget
{
TaskId = taskId,
AmountUsd = amount,
Reason = Get(input, "reasoning")!.Trim(),
RequestedBy = context.AgentId,
ApprovedAt = DateTime.UtcNow
}, ct);
// A large task that waited for this decision is taken up again.
if (await context.Tasks!.GetAsync(taskId, ct) is { Status: TaskItemStatus.Blocked, LargePlannedAt: not null })
await context.Tasks.SetStatusAsync(taskId, TaskItemStatus.Todo, DateTime.UtcNow, ct);
return ToolResult.Ok(
$"Special budget of {amount.ToString("0.00", CultureInfo.InvariantCulture)} USD for {taskId} and its parts is approved. " +
"Runs in that task tree draw on it, not on your daily budget; once it is used up, they count against the day again.");
}
private static string Describe(Decision d)
=> CaseDesk.DecisionLine(d) + (string.IsNullOrWhiteSpace(d.Reasoning) ? "" : $"\n Reasoning: {d.Reasoning}");
@@ -50,6 +50,11 @@ public sealed class TaskboardTool : IAgentTool
The time zone 'tz' is required (e.g. 'Europe/Berlin').
If 'when' is missing, the task is one-time and due immediately.
Fill work: large work that is not urgent gets 'fill_by' (at the latest by) instead of
a schedule. It runs when the day's budget has room that would otherwise go unused,
and before the deadline at the latest. Give 'estimate_usd' if you can judge the cost
of one run — it is shown as your estimate until a run has been measured.
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
@@ -116,6 +121,14 @@ public sealed class TaskboardTool : IAgentTool
"type": "string",
"description": "ID of the task this one serves (its purpose — unlike blocked_by, which is a precondition). Empty string removes it (task_update)."
},
"fill_by": {
"type": "string",
"description": "Fill work: at the latest by (ISO timestamp, or a date for the end of that day). Only for one-time tasks without 'when'. Empty string removes it (task_update)."
},
"estimate_usd": {
"type": "number",
"description": "Your estimate of what one run of the task costs, in USD. Shown as an estimate until runs have been measured."
},
"blocked_by": {
"type": "array",
"items": { "type": "string" },
@@ -312,6 +325,9 @@ public sealed class TaskboardTool : IAgentTool
if (parent.Length > 0 && await CheckParentAsync(null, parent, board, visible, ct) is { } parentProblem)
return ToolResult.Fail(parentProblem);
if (!TryReadFill(input, when, null, out var fillBy, out var estimate, out var fillError))
return ToolResult.Fail(fillError!);
// #674: The same finding a second time is not a second task. It is recorded on the
// existing one — so that later one can see how often and for how long.
if (GetBool(input, "force") != true)
@@ -356,6 +372,8 @@ public sealed class TaskboardTool : IAgentTool
// #684: decides the board when the task is for a human or a fresh run.
CreatedBy = author,
ParentId = parent,
FillBy = fillBy,
EstimateUsd = estimate,
// #697: Where it came from — the engine's knowledge, never an argument of the agent.
OriginRunId = context.RunId ?? "",
OriginTaskId = context.RunTaskId ?? ""
@@ -420,6 +438,9 @@ public sealed class TaskboardTool : IAgentTool
if (parent is { Length: > 0 } && await CheckParentAsync(id.Trim(), parent, board, visible, ct) is { } parentProblem)
return ToolResult.Fail(parentProblem);
if (!TryReadFill(input, hasWhen ? when : existing?.When, existing, out var fillBy, out var estimate, out var fillError))
return ToolResult.Fail(fillError!);
var result = await board.UpdateAsync(id.Trim(), current => current with
{
Title = GetString(input, "title")?.Trim() ?? current.Title,
@@ -432,7 +453,9 @@ public sealed class TaskboardTool : IAgentTool
Acceptance = GetString(input, "acceptance")?.Trim() ?? current.Acceptance,
BlockedBy = input.TryGetProperty("blocked_by", out _) ? GetStringList(input, "blocked_by") : current.BlockedBy,
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? current.OnlyWhenMarketOpen,
ParentId = parent ?? current.ParentId
ParentId = parent ?? current.ParentId,
FillBy = fillBy,
EstimateUsd = estimate
}, ct);
if (result is null)
@@ -516,6 +539,11 @@ public sealed class TaskboardTool : IAgentTool
block.AppendLine($" Arose from: {origin}");
if (task.BlockedBy.Count > 0)
block.AppendLine($" Waiting for: {string.Join(", ", task.BlockedBy)}");
// #717: Fill work waits for room, not for a time — say so, or it looks forgotten.
if (task.IsFillWork)
block.AppendLine($" Fill work: runs when the budget has room, at the latest by {task.FillBy:yyyy-MM-dd HH:mm} UTC");
if (task.EstimateUsd is { } usd)
block.AppendLine($" Author's estimate: {usd.ToString("0.00##", System.Globalization.CultureInfo.InvariantCulture)} USD per run (not measured)");
if (sb.Length + block.Length > MaxResultChars)
{
@@ -535,6 +563,60 @@ public sealed class TaskboardTool : IAgentTool
// ─── Eingabe lesen ───
/// <summary>
/// #717: 'fill_by' and 'estimate_usd'. Fill work is one-time and lies ahead; a schedule and a
/// deadline contradict each other. Missing fields keep what <paramref name="existing"/> has.
/// </summary>
private static bool TryReadFill(
JsonElement input, TaskWhen? when, TaskItem? existing,
out DateTime? fillBy, out decimal? estimate, out string? error)
{
fillBy = existing?.FillBy;
estimate = existing?.EstimateUsd;
error = null;
if (input.TryGetProperty("fill_by", out var f))
{
var text = f.ValueKind == JsonValueKind.String ? f.GetString()?.Trim() : null;
if (string.IsNullOrEmpty(text))
fillBy = null;
else if (TaskFrontmatter.ParseUtc(text) is { } parsed)
fillBy = parsed;
else
{
error = $"'fill_by' is not a point in time: '{text}'. Use an ISO timestamp or a date (yyyy-MM-dd).";
return false;
}
if (fillBy is { } deadline && deadline <= DateTime.UtcNow)
{
error = $"'fill_by' lies in the past ({deadline:yyyy-MM-dd HH:mm} UTC). Fill work needs a deadline ahead.";
return false;
}
}
if (fillBy is not null && when is { Kind: TaskWhenKind.Cron or TaskWhenKind.Every })
{
error = "Fill work is one-time: a task with 'fill_by' has no recurring schedule ('when').";
return false;
}
if (input.TryGetProperty("estimate_usd", out var e))
{
if (e.ValueKind == JsonValueKind.Null)
estimate = null;
else if (e.ValueKind == JsonValueKind.Number && e.TryGetDecimal(out var usd) && usd >= 0)
estimate = usd;
else
{
error = "'estimate_usd' must be a non-negative number (USD per run).";
return false;
}
}
return true;
}
private static bool TryReadWhen(JsonElement input, out TaskWhen? when, out string? error)
{
when = null;
+11 -2
View File
@@ -129,7 +129,14 @@ public sealed record TaskDto(
string? BoardPath = null,
// #697: where the task came from — stamped by the engine.
string? OriginRunId = null,
string? OriginTaskId = null);
string? OriginTaskId = null,
// #717: fill work — a deadline instead of an appointment — and the author's estimate per run.
DateTime? FillBy = null,
decimal? EstimateUsd = null,
DateTime? FillAlertedAt = null);
/// <summary>#717: A special budget — the amount a human approved for a task tree, and what it used.</summary>
public sealed record SpecialBudgetDto(string TaskId, decimal AmountUsd, decimal SpentUsd, string Reason, string RequestedBy, DateTime ApprovedAt);
/// <summary>
/// #716: Runs of this task the budget refused — only present while something is open
@@ -198,7 +205,9 @@ public sealed record TaskInputDto(
string? TimeZone = null,
string Acceptance = "",
int Priority = 3,
string? ParentId = null);
string? ParentId = null,
string? FillBy = null,
decimal? EstimateUsd = null);
/// <summary>Die Kostenprognose einer Instanz (#595).</summary>
public sealed record ForecastDto(
+7 -2
View File
@@ -113,7 +113,9 @@ internal static class Mapping
input.TimeZone,
input.Acceptance ?? "",
input.Priority,
input.ParentId ?? "");
input.ParentId ?? "",
input.FillBy,
input.EstimateUsd);
public static ForecastDto ToDto(this InstanzPrognose prognose)
=> new(
@@ -162,7 +164,10 @@ internal static class Mapping
CreatedBy: string.IsNullOrWhiteSpace(task.CreatedBy) ? null : task.CreatedBy,
ParentId: string.IsNullOrWhiteSpace(task.ParentId) ? null : task.ParentId,
OriginRunId: string.IsNullOrWhiteSpace(task.OriginRunId) ? null : task.OriginRunId,
OriginTaskId: string.IsNullOrWhiteSpace(task.OriginTaskId) ? null : task.OriginTaskId);
OriginTaskId: string.IsNullOrWhiteSpace(task.OriginTaskId) ? null : task.OriginTaskId,
FillBy: task.IsFillWork ? task.FillBy : null,
EstimateUsd: task.EstimateUsd,
FillAlertedAt: task.FillAlertedAt);
/// <summary>#684: The board of a task, for a list the human sees whole.</summary>
public static TaskDto WithBoard(this TaskDto dto, TaskItem task, Core.Organisation.OrgChart org)
@@ -55,7 +55,7 @@ public static class AnalyticsEndpoints
var points = await insights.Analytics.SeriesAsync(
ParseQuery(request),
bucket == "day" ? "day" : "hour",
groupBy is "model" or "source" ? groupBy : "agent",
groupBy is "model" or "source" or "budget" ? groupBy : "agent",
TimeSpan.FromMinutes(Math.Clamp(tzOffset, -14 * 60, 14 * 60)),
ct);
return Results.Ok(points.Select(p => new SeriesPointDto(p.BucketStart, p.Group, p.Runs, p.Tokens, p.CostUsd)).ToList());
@@ -187,7 +187,7 @@ public static class AnalyticsEndpoints
private static RunRowDto Row(Core.Audit.RunReceipt r, string? taskTitle)
=> new(r.RunId, r.AgentId, r.Model, r.Source, r.TaskId, taskTitle, r.Status,
r.StepCount, r.PromptTokens, r.CompletionTokens, r.CachedTokens, r.CostUsd, r.CostIsKnown,
r.DurationMs, r.ResultRef, r.OccurredAt, r.CallerRunId);
r.DurationMs, r.ResultRef, r.OccurredAt, r.CallerRunId, r.BudgetPot);
private static RunDetailDto ToDto(RunDetail d) => new(
d.RunId,
@@ -222,7 +222,9 @@ public sealed record RunRowDto(
string Status, int StepCount, int PromptTokens, int CompletionTokens, int CachedTokens,
decimal CostUsd, bool CostIsKnown, long DurationMs, string Result, DateTime OccurredAt,
// #697: the run this one was delegated from.
string? CallerRunId = null);
string? CallerRunId = null,
// #717: the task whose special budget the run drew on.
string? BudgetPot = null);
public sealed record RunTotalsDto(
int Runs, long PromptTokens, long CompletionTokens, long CachedTokens,
@@ -287,6 +287,18 @@ internal static class ApiEndpoints
tasks.MapPost("/{id}/run", async (AppHost host, string id, CancellationToken ct) =>
(await host.TaskAdmin.RunNowAsync(id, ct)).ToResponse());
// #717: Special budgets with what they have used — shown on the tasks they are for.
api.MapGet("/special-budgets", async (AppHost host, CancellationToken ct) =>
{
if (host.SpecialBudgets is not { } pots)
return Results.Ok(Array.Empty<SpecialBudgetDto>());
var list = new List<SpecialBudgetDto>();
foreach (var pot in await pots.ListAsync(ct))
list.Add(new SpecialBudgetDto(pot.TaskId, pot.AmountUsd, await pots.SpentAsync(pot.TaskId, ct),
pot.Reason, pot.RequestedBy, pot.ApprovedAt));
return Results.Ok(list);
});
tasks.MapGet("/{id}", async (AppHost host, string id, CancellationToken ct) =>
{
if (host.Board is not { } board)
@@ -0,0 +1,383 @@
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Budget;
/// <summary>
/// #717: Fill work — large, not urgent — runs on budget the day would otherwise leave unused,
/// and never overruns its deadline silently. Whether there is room is decided by the code.
/// </summary>
public sealed class FillWorkTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
private readonly SqliteTaskRepository _repo;
private readonly InMemoryAuditRepository _audit = new();
// 2027-03-10 14:00 UTC; the clocks' local zone is UTC.
private static readonly DateTime Afternoon = new(2027, 3, 10, 14, 0, 0, DateTimeKind.Utc);
private static readonly DateTime Evening = new(2027, 3, 10, 20, 0, 0, DateTimeKind.Utc);
public FillWorkTests()
{
_repo = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private static AgentConfig Agent(decimal dailyCost = 1.00m)
=> new() { AgentId = "worker", Budget = new AgentBudget { DailyCostUsd = dailyCost } };
/// <summary>The agent's runs average 0.05 USD over 1,000 tokens.</summary>
private static BudgetGuard Guard(decimal used = 0m)
=> new(new FakeUsage(used, [.. Enumerable.Range(0, 5).Select(_ => new RunUsage
{
AgentId = "worker", PromptTokens = 1_000, CostUsd = 0.05m, CostIsKnown = true
})]));
private static TaskItem Fill(string id, DateTime deadline, decimal? estimate = null, string parent = "") => new()
{
Id = id, Title = id, Assignee = "@worker", FillBy = deadline, EstimateUsd = estimate, ParentId = parent,
CreatedAt = Afternoon.AddDays(-1)
};
private void Receipt(string taskId, decimal cost, int tokens = 2_000)
=> _audit.Receipts.Enqueue(new RunReceipt
{
RunId = Guid.NewGuid().ToString("N"), AgentId = "worker", TaskId = taskId,
PromptTokens = tokens, CostUsd = cost, OccurredAt = Afternoon
});
// ═══════════════════ estimating, honestly ═══════════════════
[Fact]
public async Task Measured_runs_of_the_task_beat_everything_else()
{
var task = await _repo.UpsertAsync(Fill("t-1", Evening.AddDays(3), estimate: 5m), default);
Receipt("t-1", 0.40m);
Receipt("t-1", 0.60m);
var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(task, default);
estimate.Source.ShouldBe(EstimateSource.Measured);
estimate.Run.CostUsd.ShouldBe(0.50m);
estimate.Samples.ShouldBe(2);
estimate.IsGuess.ShouldBeFalse();
}
[Fact]
public async Task A_refusal_without_usage_is_not_a_measurement()
{
var task = await _repo.UpsertAsync(Fill("t-1", Evening.AddDays(3), estimate: 2m), default);
Receipt("t-1", 0m, tokens: 0);
var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(task, default);
estimate.Source.ShouldBe(EstimateSource.Stated, "a budget refusal costs nothing and says nothing");
}
[Fact]
public async Task After_the_first_part_ran_its_siblings_are_estimated_from_it()
{
await _repo.UpsertAsync(new TaskItem { Id = "t-big", Title = "big", CreatedAt = Afternoon }, default);
await _repo.UpsertAsync(Fill("t-part-1", Evening.AddDays(3), estimate: 3m, parent: "t-big"), default);
var second = await _repo.UpsertAsync(Fill("t-part-2", Evening.AddDays(3), estimate: 3m, parent: "t-big"), default);
Receipt("t-part-1", 0.80m);
var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(second, default);
estimate.Source.ShouldBe(EstimateSource.Siblings);
estimate.Run.CostUsd.ShouldBe(0.80m, "the measured part replaces the author's guess of 3 USD");
}
[Fact]
public async Task A_stated_estimate_is_a_guess_and_gets_tokens_at_the_agents_price()
{
var task = await _repo.UpsertAsync(Fill("t-1", Evening.AddDays(3), estimate: 0.50m), default);
var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(task, default);
estimate.Source.ShouldBe(EstimateSource.Stated);
estimate.IsGuess.ShouldBeTrue();
estimate.Run.Tokens.ShouldBe(10_000, "0.50 USD at 0.05 USD per 1,000 tokens");
FillWorkNotice.Describe(estimate).ShouldContain("estimated by its author, not measured");
}
[Fact]
public async Task Without_anything_the_agents_average_is_used_and_called_a_guess()
{
var task = await _repo.UpsertAsync(Fill("t-1", Evening.AddDays(3)), default);
var estimate = await new TaskCostEstimator(_audit, _repo, Guard()).EstimateAsync(task, default);
estimate.Source.ShouldBe(EstimateSource.AgentAverage);
estimate.Run.CostUsd.ShouldBe(0.05m);
estimate.IsGuess.ShouldBeTrue();
}
// ═══════════════════ is there room? ═══════════════════
private BudgetHeadroom Headroom(DateTime now, decimal used, AgentConfig? agent = null, FillWorkSettings? settings = null)
{
var guard = Guard(used);
return new BudgetHeadroom(guard, () => [agent ?? Agent()], () => InstanceBudget.Unlimited, new UtcClock(now),
new TaskCostEstimator(_audit, _repo, guard), () => settings ?? new FillWorkSettings());
}
[Fact]
public async Task In_the_afternoon_fill_work_waits_for_the_evening()
{
var fill = Fill("t-fill", Evening.AddDays(3), estimate: 0.20m);
(await Headroom(Afternoon, used: 0m).CheckAsync(fill, [fill], Afternoon, default))
.ShouldBe(FillVerdict.NotYet, "at 14:00 'there is room' is a guess");
}
[Fact]
public async Task In_the_evening_it_runs_when_its_estimate_fits()
{
// Limit 1.00, used 0.50, margin 0.10: 0.40 left for an estimate of 0.30.
var fill = Fill("t-fill", Evening.AddDays(3), estimate: 0.30m);
(await Headroom(Evening, used: 0.50m).CheckAsync(fill, [fill], Evening, default)).ShouldBe(FillVerdict.Run);
}
[Fact]
public async Task It_is_measured_with_its_own_estimate_not_the_agents_average()
{
// 0.40 left — enough for the agent's average run of 0.05, not for this task's 0.60.
var fill = Fill("t-fill", Evening.AddDays(3), estimate: 0.60m);
(await Headroom(Evening, used: 0.50m).CheckAsync(fill, [fill], Evening, default)).ShouldBe(FillVerdict.NoRoom);
}
[Fact]
public async Task The_rest_of_the_days_schedule_comes_first()
{
// Four hourly runs à 0.05 until midnight (20:00 … 23:00) = 0.20: 1.00 - 0.50 - 0.20 - 0.10 = 0.20 < 0.30.
var hourly = new TaskItem
{
Id = "t-hourly", Title = "hourly", Assignee = "@worker", CreatedAt = Afternoon.AddDays(-1),
LastOccurrence = "2027-03-10T19:00:00.0000000Z",
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 * * * *", TimeZone = "UTC" }
};
var fill = Fill("t-fill", Evening.AddDays(3), estimate: 0.30m);
(await Headroom(Evening, used: 0.50m).CheckAsync(fill, [hourly, fill], Evening, default)).ShouldBe(FillVerdict.NoRoom);
}
[Fact]
public async Task Close_to_its_deadline_it_no_longer_waits_for_the_evening()
{
var fill = Fill("t-fill", Afternoon.AddHours(10), estimate: 0.20m);
(await Headroom(Afternoon, used: 0m).CheckAsync(fill, [fill], Afternoon, default)).ShouldBe(FillVerdict.Run);
}
[Fact]
public async Task Without_a_limit_there_is_nothing_to_wait_for()
{
var fill = Fill("t-fill", Evening.AddDays(3), estimate: 50m);
(await Headroom(Afternoon, used: 0m, agent: Agent(dailyCost: 0m)).CheckAsync(fill, [fill], Afternoon, default))
.ShouldBe(FillVerdict.Run);
}
// ═══════════════════ the scanner ═══════════════════
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(Evening, TimeSpan.Zero));
private TaskScanner Scanner(RecordingDispatcher dispatcher, IFillGate gate, List<TaskItem>? asked = null, int maxParallel = 4)
=> new(_repo, dispatcher, NullLoggerFactory.Instance, clock: _clock, maxParallelRuns: maxParallel,
fillGate: gate,
askAboutFill: (task, _) => { asked?.Add(task); return Task.CompletedTask; },
fillWarnBefore: () => TimeSpan.FromHours(24));
[Fact]
public async Task Fill_work_starts_only_when_the_gate_sees_room()
{
await _repo.UpsertAsync(Fill("t-fill", Evening.AddDays(3)), default);
var dispatcher = new RecordingDispatcher();
var gate = new FixedGate(FillVerdict.NoRoom);
(await Scanner(dispatcher, gate).ScanOnceAsync(default)).ShouldBe(0);
gate.Verdict = FillVerdict.Run;
(await Scanner(dispatcher, gate).ScanOnceAsync(default)).ShouldBe(1);
dispatcher.Started.ShouldBe(["t-fill"]);
}
[Fact]
public async Task Fill_work_waits_while_other_work_of_its_agent_waits()
{
// A carried-over task of the same agent waits for headroom. The fill task would fit —
// but late work was due and fill work never was, so it does not overtake it.
await _repo.UpsertAsync(new TaskItem { Id = "t-carried", Title = "carried", Assignee = "@worker" }, default);
await _repo.TryClaimAsync("t-carried", "occ-1", "token", Evening, Evening.AddMinutes(-15), default);
await _repo.CarryOverAsync("t-carried", "token", null, Evening.AddMinutes(-1), Evening, default);
await _repo.UpsertAsync(Fill("t-fill", Evening.AddDays(3)), default);
await _repo.UpsertAsync(Fill("t-fill-other", Evening.AddDays(3)) with { Assignee = "@other" }, default);
var dispatcher = new RecordingDispatcher();
var scanner = new TaskScanner(_repo, dispatcher, NullLoggerFactory.Instance, clock: _clock,
lateRunGate: new NoRoomForLateWork(), fillGate: new FixedGate(FillVerdict.Run));
await scanner.ScanOnceAsync(default);
dispatcher.Started.ShouldBe(["t-fill-other"], "another agent's fill work is not held back");
}
[Fact]
public async Task Regular_work_goes_first_and_the_nearest_deadline_leads_the_fill_work()
{
await _repo.UpsertAsync(Fill("t-fill-late", Evening.AddDays(5)), default);
await _repo.UpsertAsync(Fill("t-fill-soon", Evening.AddDays(2)), default);
await _repo.UpsertAsync(new TaskItem { Id = "t-regular", Title = "regular", Assignee = "@worker", Priority = 1 }, default);
var dispatcher = new RecordingDispatcher();
await Scanner(dispatcher, new FixedGate(FillVerdict.Run)).ScanOnceAsync(default);
dispatcher.Started.ShouldBe(["t-regular", "t-fill-soon", "t-fill-late"]);
}
[Fact]
public async Task A_human_is_asked_once_when_the_deadline_comes_close()
{
await _repo.UpsertAsync(Fill("t-fill", Evening.AddHours(20)), default);
var asked = new List<TaskItem>();
var scanner = Scanner(new RecordingDispatcher(), new FixedGate(FillVerdict.NoRoom), asked);
await scanner.ScanOnceAsync(default);
await scanner.ScanOnceAsync(default);
asked.Select(t => t.Id).ShouldBe(["t-fill"], "asked once, not every minute");
(await _repo.GetAsync("t-fill", default))!.FillAlertedAt.ShouldNotBeNull();
}
[Fact]
public async Task Far_from_the_deadline_nobody_is_bothered()
{
await _repo.UpsertAsync(Fill("t-fill", Evening.AddDays(3)), default);
var asked = new List<TaskItem>();
await Scanner(new RecordingDispatcher(), new FixedGate(FillVerdict.NoRoom), asked).ScanOnceAsync(default);
asked.ShouldBeEmpty();
}
[Fact]
public async Task Past_its_deadline_fill_work_runs_as_regular_work()
{
await _repo.UpsertAsync(Fill("t-fill", Evening.AddHours(2)), default);
_clock.SetUtcNow(new DateTimeOffset(Evening.AddHours(3), TimeSpan.Zero));
var dispatcher = new RecordingDispatcher();
(await Scanner(dispatcher, new FixedGate(FillVerdict.NoRoom)).ScanOnceAsync(default)).ShouldBe(1);
dispatcher.Started.ShouldBe(["t-fill"], "the deadline was the promise");
}
[Fact]
public void The_question_to_the_human_hangs_below_the_fill_work()
{
var question = FillWorkNotice.QuestionFor(Fill("t-fill", Evening.AddDays(1)),
new TaskEstimate(new RunEstimate(0.80m, 0), EstimateSource.Measured, 3));
question.Assignee.ShouldBe(TaskAssignee.Human);
question.ParentId.ShouldBe("t-fill");
question.Body.ShouldContain("0.80 USD (measured over 3 run(s))");
question.Body.ShouldContain("raise the agent's daily budget");
}
// ═══════════════════ the definition ═══════════════════
[Fact]
public void Deadline_and_estimate_survive_the_file()
{
var task = Fill("t-fill", new DateTime(2027, 3, 12, 18, 0, 0, DateTimeKind.Utc), estimate: 1.25m);
TaskFrontmatter.TryParse(TaskFrontmatter.Serialize(task), out var read, out _).ShouldBeTrue();
read.FillBy.ShouldBe(task.FillBy);
read.EstimateUsd.ShouldBe(1.25m);
read.IsFillWork.ShouldBeTrue();
}
[Fact]
public void A_bare_date_means_the_end_of_that_day()
=> TaskFrontmatter.ParseUtc("2027-03-12").ShouldBe(new DateTime(2027, 3, 13, 0, 0, 0, DateTimeKind.Utc));
[Fact]
public async Task Deadline_and_estimate_survive_the_database()
{
await _repo.UpsertAsync(Fill("t-fill", new DateTime(2027, 3, 12, 18, 0, 0, DateTimeKind.Utc), estimate: 0.75m), default);
var read = (await _repo.GetAsync("t-fill", default))!;
read.FillBy.ShouldBe(new DateTime(2027, 3, 12, 18, 0, 0, DateTimeKind.Utc));
read.EstimateUsd.ShouldBe(0.75m);
}
[Fact]
public void A_recurring_task_is_never_fill_work()
=> (Fill("t", Evening) with { When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "1h", TimeZone = "UTC" } })
.IsFillWork.ShouldBeFalse();
// ═══════════════════ test doubles ═══════════════════
private sealed class FixedGate(FillVerdict verdict) : IFillGate
{
public FillVerdict Verdict { get; set; } = verdict;
public Task<FillVerdict> CheckAsync(TaskItem fillTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
=> Task.FromResult(Verdict);
}
private sealed class NoRoomForLateWork : ILateRunGate
{
public Task<bool> HasRoomAsync(TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
=> Task.FromResult(false);
}
private sealed class RecordingDispatcher : ITaskDispatcher
{
public List<string> Started { get; } = [];
public Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
{
lock (Started)
Started.Add(task.Id);
return Task.FromResult(TaskLauf.Erledigt);
}
}
private sealed class UtcClock(DateTime now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(now, TimeSpan.Zero);
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
private sealed class FakeUsage(decimal usedCost, IReadOnlyList<RunUsage> recent) : IUsageRepository
{
public Task RecordAsync(RunUsage usage, CancellationToken ct) => Task.CompletedTask;
public Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
=> Task.FromResult(new DailyUsage(date, 0, 0, usedCost, true, 0));
public Task<IReadOnlyList<RunUsage>> GetRecentAsync(int limit, CancellationToken ct) => Task.FromResult(recent);
public Task<IReadOnlyList<RunUsage>> GetRecentForAgentAsync(string agentId, int limit, CancellationToken ct)
=> Task.FromResult(recent);
public Task<int> PurgeBeforeAsync(DateOnly date, CancellationToken ct) => Task.FromResult(0);
}
}
@@ -0,0 +1,290 @@
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Budget;
/// <summary>
/// #717: Large tasks and special budgets. A task too large for the day is not simply started —
/// its run proposes a split or a special budget. A special budget is a pot of its own: its
/// runs stay outside the daily limits and are visible apart.
/// </summary>
public sealed class SpecialBudgetTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
private readonly SqliteStorage _storage;
private readonly SqliteTaskRepository _tasks;
private readonly SqliteSpecialBudgetRepository _pots;
private readonly SqliteUsageRepository _usage;
public SpecialBudgetTests()
{
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_tasks = new SqliteTaskRepository(_storage);
_pots = new SqliteSpecialBudgetRepository(_storage);
_usage = new SqliteUsageRepository(_storage);
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private Task<TaskItem> Seed(string id, string parent = "", decimal? estimate = null, string assignee = "@worker")
=> _tasks.UpsertAsync(new TaskItem { Id = id, Title = id, Assignee = assignee, ParentId = parent, EstimateUsd = estimate }, default);
private Task Pot(string taskId, decimal amount)
=> _pots.SetAsync(new SpecialBudget { TaskId = taskId, AmountUsd = amount, Reason = "one big job", RequestedBy = "worker", ApprovedAt = DateTime.UtcNow }, default);
// ═══════════════════ the pot ═══════════════════
[Fact]
public async Task A_part_draws_on_the_pot_of_the_task_above_it()
{
await Seed("t-big");
await Seed("t-part", parent: "t-big");
await Seed("t-other");
await Pot("t-big", 5m);
(await SpecialBudgets.FindAsync("t-part", _tasks, _pots, default))!.TaskId.ShouldBe("t-big");
(await SpecialBudgets.FindAsync("t-other", _tasks, _pots, default)).ShouldBeNull("outside the tree it does not exist");
}
[Fact]
public async Task A_circle_in_the_tree_does_not_hang_the_lookup()
{
await Seed("t-a", parent: "t-b");
await Seed("t-b", parent: "t-a");
(await SpecialBudgets.FindAsync("t-a", _tasks, _pots, default)).ShouldBeNull();
}
[Fact]
public async Task Runs_on_the_pot_count_for_the_pot_and_not_against_the_day()
{
var today = DateOnly.FromDateTime(DateTime.Now);
await _usage.RecordAsync(new RunUsage { AgentId = "worker", PromptTokens = 1_000, CostUsd = 0.10m, OccurredAt = DateTime.Now }, default);
await _usage.RecordAsync(new RunUsage { AgentId = "worker", PromptTokens = 9_000, CostUsd = 2.50m, OccurredAt = DateTime.Now, BudgetPot = "t-big" }, default);
(await _pots.SpentAsync("t-big", default)).ShouldBe(2.50m);
(await _usage.GetDailyForBudgetAsync(today, "worker", default)).CostUsd.ShouldBe(0.10m);
(await _usage.GetDailyAsync(today, "worker", default)).CostUsd.ShouldBe(2.60m, "what was used is still shown in full");
(await _usage.GetRecentForAgentAsync("worker", 10, default)).ShouldHaveSingleItem()
.CostUsd.ShouldBe(0.10m, "a pot run would inflate the estimate of an ordinary run");
}
[Fact]
public async Task The_pot_refuses_once_it_is_used_up_and_counts_runs_in_flight()
{
var guard = new BudgetGuard(_usage);
await _usage.RecordAsync(new RunUsage { AgentId = "worker", PromptTokens = 1_000, CostUsd = 0.40m, OccurredAt = DateTime.Now }, default);
var pot = new SpecialBudget { TaskId = "t-big", AmountUsd = 1.00m };
// The agent's run averages 0.40. Used 0.50: the first run finds 0.50, the second 0.90 —
// both below 1.00. The third finds 1.30 and is refused: runs in flight count.
(await guard.ReservePotAsync(pot, spent: 0.50m, "worker", default)).ShouldNotBeNull();
(await guard.ReservePotAsync(pot, spent: 0.50m, "worker", default)).ShouldNotBeNull();
(await guard.ReservePotAsync(pot, spent: 0.50m, "worker", default)).ShouldBeNull();
(await guard.ReservePotAsync(pot, spent: 1.00m, "worker", default) is null).ShouldBeTrue("used up is used up");
}
[Fact]
public async Task A_run_in_the_tree_takes_place_although_the_day_is_used_up()
{
await Seed("t-big");
await Seed("t-part", parent: "t-big");
await Seed("t-other");
await Pot("t-big", 5m);
await _usage.RecordAsync(new RunUsage { AgentId = "worker", PromptTokens = 5_000, OccurredAt = DateTime.Now }, default);
var audit = new InMemoryAuditRepository();
var fixture = new EngineFixture(audit, usage: _usage, tasks: _tasks);
fixture.Engine.SpecialBudgets = _pots;
var agent = fixture.AddAgent("worker");
agent.Budget.DailyTokens = 1_000; // used up
fixture.Client.RespondsWithText("part done").RespondsWithText("part done").RespondsWithText("part done");
var funded = await fixture.Engine.RunAsync(agent, "Do part 1", "inst", default, taskId: "t-part");
var other = await fixture.Engine.RunAsync(agent, "Do something else", "inst", default, taskId: "t-other");
funded.Status.ShouldBe(AgentRunStatus.Completed, funded.Error?.Message);
other.Status.ShouldBe(AgentRunStatus.BudgetExceeded, "the pot does not raise the daily limit");
audit.Receipts.First(r => r.TaskId == "t-part").BudgetPot.ShouldBe("t-big");
audit.Receipts.First(r => r.TaskId == "t-other").BudgetPot.ShouldBeNull();
(await _usage.GetDailyForBudgetAsync(DateOnly.FromDateTime(DateTime.Now), "worker", default)).TotalTokens
.ShouldBe(5_000, "the pot run stayed outside the day");
}
[Fact]
public async Task The_cost_analysis_shows_the_pot_apart()
{
var audit = new InMemoryAuditRepository();
audit.Receipts.Enqueue(new RunReceipt { RunId = "r1", AgentId = "worker", CostUsd = 0.10m, OccurredAt = DateTime.UtcNow });
audit.Receipts.Enqueue(new RunReceipt { RunId = "r2", AgentId = "worker", CostUsd = 2.00m, OccurredAt = DateTime.UtcNow, BudgetPot = "t-big" });
var analytics = new Core.Analytics.UsageAnalytics(audit, null);
var points = await analytics.SeriesAsync(
new Core.Analytics.RunQuery { FromUtc = DateTime.UtcNow.AddDays(-1), ToUtc = DateTime.UtcNow.AddDays(1) },
"day", "budget", TimeSpan.Zero, default);
points.Select(p => p.Group).OrderBy(g => g).ShouldBe(["daily", "special:t-big"]);
}
// ═══════════════════ large tasks ═══════════════════
private LargeTaskCheck Check(decimal dailyLimit = 1.00m)
{
var guard = new BudgetGuard(_usage);
return new LargeTaskCheck(new TaskCostEstimator(new InMemoryAuditRepository(), _tasks, guard), _tasks, _pots,
() => [new AgentConfig { AgentId = "worker", Budget = new AgentBudget { DailyCostUsd = dailyLimit } }],
() => new FillWorkSettings());
}
[Fact]
public async Task A_task_estimated_above_half_the_day_gets_a_planning_run()
{
var task = await Seed("t-big", estimate: 0.80m);
var instruction = await Check().PlanningInstructionAsync(task, default);
instruction.ShouldNotBeNull();
instruction.ShouldContain("Do not start the work");
instruction.ShouldContain("parent='t-big'");
instruction.ShouldContain("request_budget");
instruction.ShouldContain("estimated by its author, not measured");
}
[Theory]
[InlineData(0.40, 1.00)] // below half
[InlineData(0.80, 0.00)] // no cost limit — nothing to protect
public async Task Otherwise_it_simply_runs(double estimate, double limit)
{
var task = await Seed("t-1", estimate: (decimal)estimate);
(await Check((decimal)limit).PlanningInstructionAsync(task, default)).ShouldBeNull();
}
[Fact]
public async Task The_agents_average_run_does_not_make_a_task_large()
{
await _usage.RecordAsync(new RunUsage { AgentId = "worker", PromptTokens = 1_000, CostUsd = 0.90m, OccurredAt = DateTime.Now }, default);
var task = await Seed("t-1");
(await Check().PlanningInstructionAsync(task, default)).ShouldBeNull("that says nothing about this task");
}
[Fact]
public async Task A_split_or_funded_task_is_not_planned_again()
{
var split = await Seed("t-split", estimate: 0.80m);
await Seed("t-split-1", parent: "t-split");
var funded = await Seed("t-funded", estimate: 0.80m);
await Pot("t-funded", 3m);
(await Check().PlanningInstructionAsync(split, default)).ShouldBeNull();
(await Check().PlanningInstructionAsync(funded, default)).ShouldBeNull();
}
// ═══════════════════ the scanner ═══════════════════
private TaskScanner Scanner(PlanningDispatcher dispatcher, List<string>? notes = null)
=> new(_tasks, dispatcher, NullLoggerFactory.Instance, largeTaskCheck: Check(),
noteOnTask: (id, note, _) => { notes?.Add($"{id}: {note}"); return Task.CompletedTask; });
[Fact]
public async Task The_planning_run_is_told_so_and_the_task_waits_for_its_parts()
{
await Seed("t-big", estimate: 0.80m);
var notes = new List<string>();
var dispatcher = new PlanningDispatcher(async task =>
{
// The agent splits it.
await Seed("t-big-1", parent: "t-big");
await Seed("t-big-2", parent: "t-big");
});
await Scanner(dispatcher, notes).ScanOnceAsync(default);
dispatcher.Instructions.ShouldHaveSingleItem().ShouldContain("Planning run");
var big = (await _tasks.GetAsync("t-big", default))!;
big.Status.ShouldBe(TaskItemStatus.Blocked);
big.LargePlannedAt.ShouldNotBeNull();
notes.ShouldHaveSingleItem().ShouldContain("split into 2 part(s)");
}
[Fact]
public async Task When_the_last_part_is_done_the_large_task_runs_again_once()
{
await Seed("t-big", estimate: 0.80m);
var dispatcher = new PlanningDispatcher(async _ => await Seed("t-big-1", parent: "t-big"));
var scanner = Scanner(dispatcher);
await scanner.ScanOnceAsync(default); // planning; the part is created
await scanner.ScanOnceAsync(default); // the part runs and is done
(await _tasks.GetAsync("t-big", default))!.Status.ShouldBe(TaskItemStatus.Todo);
await scanner.ScanOnceAsync(default); // the large task brings its parts together
(await _tasks.GetAsync("t-big", default))!.Status.ShouldBe(TaskItemStatus.Done);
dispatcher.Instructions.Count.ShouldBe(1, "planned once, not at every run");
dispatcher.Started.ShouldBe(["t-big", "t-big-1", "t-big"]);
}
[Fact]
public async Task Without_a_split_it_waits_for_the_budget_decision_and_says_so()
{
await Seed("t-big", estimate: 0.80m);
var notes = new List<string>();
await Scanner(new PlanningDispatcher(_ => Task.CompletedTask), notes).ScanOnceAsync(default);
(await _tasks.GetAsync("t-big", default))!.Status.ShouldBe(TaskItemStatus.Blocked);
notes.ShouldHaveSingleItem().ShouldContain("waits for a special budget");
}
[Fact]
public async Task Approved_during_planning_it_runs_on_the_pot_next()
{
await Seed("t-big", estimate: 0.80m);
var dispatcher = new PlanningDispatcher(_ => Pot("t-big", 3m));
var scanner = Scanner(dispatcher);
await scanner.ScanOnceAsync(default);
(await _tasks.GetAsync("t-big", default))!.Status.ShouldBe(TaskItemStatus.Todo);
(await scanner.ScanOnceAsync(default)).ShouldBe(1, "its occurrence was given back — the planning run did not do the work");
(await _tasks.GetAsync("t-big", default))!.Status.ShouldBe(TaskItemStatus.Done);
}
private sealed class PlanningDispatcher(Func<TaskItem, Task> onPlanning) : ITaskDispatcher
{
public List<string> Instructions { get; } = [];
public List<string> Started { get; } = [];
public Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
{
Started.Add(task.Id);
return Task.FromResult(TaskLauf.Erledigt);
}
public async Task<TaskLauf> DispatchAsync(TaskItem task, string? instruction, CancellationToken ct)
{
Started.Add(task.Id);
if (instruction is not null)
{
Instructions.Add(instruction);
await onPlanning(task);
}
return TaskLauf.Erledigt;
}
}
}
@@ -26,7 +26,8 @@ internal sealed class EngineFixture
public EngineFixture(
IAuditRepository? audit = null, StagingGate? staging = null, TimeProvider? clock = null,
IUsageRepository? usage = null, Core.Transcripts.IRunTranscriptStore? transcripts = null)
IUsageRepository? usage = null, Core.Transcripts.IRunTranscriptStore? transcripts = null,
Core.Tasks.ITaskRepository? tasks = null)
{
Engine = new AgentEngine(
Client,
@@ -35,6 +36,7 @@ internal sealed class EngineFixture
StateStore,
TestLogging.Factory,
usageRepository: usage,
taskRepository: tasks,
auditRepository: audit,
stagingGate: staging,
clock: clock)
@@ -0,0 +1,92 @@
using System.Text.Json;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Tools.Taskboard;
using ClawdDotNet.Tools.Tests.FileRW;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.Taskboard;
/// <summary>#717: Asking for a special budget — the agent proposes, a human always decides.</summary>
public sealed class BudgetRequestTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
private readonly SqliteTaskRepository _tasks;
private readonly SqliteSpecialBudgetRepository _pots;
private readonly EscalationTool _tool = new();
public BudgetRequestTests()
{
var storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_tasks = new SqliteTaskRepository(storage);
_pots = new SqliteSpecialBudgetRepository(storage);
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private AgentToolContext Context(string agent = "worker") => new(
agent, "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance,
CancellationToken.None, Tasks: _tasks)
{
SpecialBudgets = _pots
};
private static JsonElement Request(string task, decimal amount = 4m, string reasoning = "400 listings, one pass")
=> JsonSerializer.SerializeToElement(new { action = "request_budget", task, amount_usd = amount, reasoning });
[Fact]
public void A_special_budget_always_needs_a_human()
{
FreigabeRegeln.IstImmerFreigabe(CaseDeskToolName, "request_budget").ShouldBeTrue();
// Not even a tool configuration that allows it, or the tool's own default, lifts it.
FreigabeRegeln.Einstufen(CaseDeskToolName, "request_budget", Werkzeugrechte.Keine, StagingDecision.Auto, new StagingPolicy())
.Entscheidung.ShouldBe(StagingDecision.Approve);
}
private const string CaseDeskToolName = Core.Cases.CaseDesk.ToolName;
[Fact]
public async Task Only_for_ones_own_task()
{
await _tasks.UpsertAsync(new TaskItem { Id = "t-1", Title = "big", Assignee = "@someone-else" }, default);
_tool.Preflight(Request("t-1"), Context()).ShouldNotBeNull().ShouldContain("neither assigned to you nor created by you");
}
[Theory]
[InlineData("", 4, "why")]
[InlineData("t-1", 0, "why")]
[InlineData("t-1", 4, "")]
public async Task An_incomplete_request_does_not_reach_a_human(string task, double amount, string reasoning)
{
await _tasks.UpsertAsync(new TaskItem { Id = "t-1", Title = "big", Assignee = "@worker" }, default);
_tool.Preflight(Request(task, (decimal)amount, reasoning), Context()).ShouldNotBeNull();
}
[Fact]
public async Task Once_approved_the_pot_is_set_up_and_the_waiting_task_taken_up_again()
{
await _tasks.UpsertAsync(new TaskItem { Id = "t-1", Title = "big", Assignee = "@worker" }, default);
await _tasks.SetStatusAsync("t-1", TaskItemStatus.Blocked, DateTime.UtcNow, default);
await _tasks.MarkLargePlannedAsync("t-1", DateTime.UtcNow, default);
_tool.Preflight(Request("t-1"), Context()).ShouldBeNull();
// What the staging runs after the human's approval:
var result = await _tool.ExecuteAsync(Request("t-1"), Context(), default);
result.Success.ShouldBeTrue(result.ErrorMessage);
(await _pots.GetAsync("t-1", default))!.AmountUsd.ShouldBe(4m);
(await _tasks.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo);
}
}
@@ -0,0 +1,86 @@
using System.Text.Json;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Tools.Taskboard;
using ClawdDotNet.Tools.Tests.FileRW;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.Taskboard;
/// <summary>#717: An agent hands out fill work — a deadline instead of an appointment, and its estimate.</summary>
public sealed class FillWorkToolTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
private readonly SqliteTaskRepository _repo;
private readonly TaskboardTool _tool = new();
public FillWorkToolTests()
{
_repo = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private Task<ToolResult> Run(object input)
=> _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), new AgentToolContext(
"lead", "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance,
CancellationToken.None, SharedWorkspacePath: Path.Combine(_directory, "SharedWorkspace"), Tasks: _repo),
CancellationToken.None);
private static string IdOf(ToolResult result) => result.Content.Split(' ', 4)[2];
private static string InAWeek => DateTime.UtcNow.AddDays(7).ToString("yyyy-MM-dd");
[Fact]
public async Task Fill_work_is_created_with_its_deadline_and_estimate()
{
var created = await Run(new { action = "task_create", title = "Rewrite all 400 listings", assignee = "@ads", fill_by = InAWeek, estimate_usd = 1.5 });
created.Success.ShouldBeTrue(created.ErrorMessage);
var task = (await _repo.GetAsync(IdOf(created), default))!;
task.IsFillWork.ShouldBeTrue();
task.EstimateUsd.ShouldBe(1.5m);
var list = (await Run(new { action = "task_list" })).Content;
list.ShouldContain("Fill work: runs when the budget has room");
list.ShouldContain("Author's estimate: 1.50 USD per run (not measured)");
}
[Fact]
public async Task A_recurring_task_cannot_be_fill_work()
{
var result = await Run(new
{
action = "task_create", title = "Hourly check", fill_by = InAWeek,
when = new { kind = "every", value = "1h", tz = "UTC" }
});
result.Success.ShouldBeFalse();
result.ErrorMessage!.ShouldContain("one-time");
}
[Fact]
public async Task A_deadline_in_the_past_is_refused()
{
var result = await Run(new { action = "task_create", title = "Too late", fill_by = "2020-01-01" });
result.ErrorMessage!.ShouldContain("lies in the past");
}
[Fact]
public async Task An_empty_deadline_turns_fill_work_back_into_regular_work()
{
var id = IdOf(await Run(new { action = "task_create", title = "Rewrite all 400 listings", assignee = "@lead", fill_by = InAWeek }));
(await Run(new { action = "task_update", id, fill_by = "" })).Success.ShouldBeTrue();
(await _repo.GetAsync(id, default))!.IsFillWork.ShouldBeFalse();
}
}