diff --git a/docs/Organisation-Konzept.md b/docs/Organisation-Konzept.md
index 7e256c9..9fe5d0c 100644
--- a/docs/Organisation-Konzept.md
+++ b/docs/Organisation-Konzept.md
@@ -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) —
diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs
index b1e7da7..78732f9 100644
--- a/src/ClawdDotNet.App/AppHost.cs
+++ b/src/ClawdDotNet.App/AppHost.cs
@@ -67,6 +67,9 @@ public sealed class AppHost : IAsyncDisposable
/// #689: The desk a human decides at; null without a task board.
public Core.Cases.ICaseDesk? CaseDesk => Engine?.CaseDesk;
+ /// #717: Special budgets — one pot per task tree, approved by a human.
+ public Core.Budget.ISpecialBudgetRepository? SpecialBudgets { get; init; }
+
///
/// 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)
@@ -809,7 +816,12 @@ public sealed class AppHost : IAsyncDisposable
var estimate = estimator is null ? TaskEstimate.Nothing : await estimator.EstimateAsync(task, ct);
await board.CreateAsync(FillWorkNotice.QuestionFor(task, estimate), ct);
},
- fillWarnBefore: () => instance.FillWork.WarnBeforeDeadline);
+ 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 () =>
diff --git a/src/ClawdDotNet.Core/Accounting/SqliteUsageRepository.cs b/src/ClawdDotNet.Core/Accounting/SqliteUsageRepository.cs
index 40df9f4..e49f066 100644
--- a/src/ClawdDotNet.Core/Accounting/SqliteUsageRepository.cs
+++ b/src/ClawdDotNet.Core/Accounting/SqliteUsageRepository.cs
@@ -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 GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
+ public Task GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
+ => GetDailyCoreAsync(date, agentId, withoutPots: false, ct);
+
+ public Task GetDailyForBudgetAsync(DateOnly date, string agentId, CancellationToken ct)
+ => GetDailyCoreAsync(date, agentId, withoutPots: true, ct);
+
+ private async Task 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
""";
diff --git a/src/ClawdDotNet.Core/Accounting/UsageModels.cs b/src/ClawdDotNet.Core/Accounting/UsageModels.cs
index f66d403..9f0acc7 100644
--- a/src/ClawdDotNet.Core/Accounting/UsageModels.cs
+++ b/src/ClawdDotNet.Core/Accounting/UsageModels.cs
@@ -28,6 +28,12 @@ public sealed record RunUsage
public long DurationMs { get; init; }
public DateTime OccurredAt { get; init; }
+
+ ///
+ /// #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.
+ ///
+ public string BudgetPot { get; init; } = "";
}
/// Verbrauch eines Tages, aggregiert.
@@ -51,6 +57,13 @@ public interface IUsageRepository
/// Tagesverbrauch eines Agenten. leer = ganze Instanz.
Task GetDailyAsync(DateOnly date, string agentId, CancellationToken ct);
+ ///
+ /// #717: What counts against the daily limits — the day's usage without runs that drew on
+ /// a special budget. keeps showing everything that was used.
+ ///
+ Task GetDailyForBudgetAsync(DateOnly date, string agentId, CancellationToken ct)
+ => GetDailyAsync(date, agentId, ct);
+
Task> GetRecentAsync(int limit, CancellationToken ct);
///
diff --git a/src/ClawdDotNet.Core/Analytics/UsageAnalytics.cs b/src/ClawdDotNet.Core/Analytics/UsageAnalytics.cs
index 953d188..4e6185f 100644
--- a/src/ClawdDotNet.Core/Analytics/UsageAnalytics.cs
+++ b/src/ClawdDotNet.Core/Analytics/UsageAnalytics.cs
@@ -88,7 +88,7 @@ public sealed class UsageAnalytics(
}
/// hour or day.
- /// agent, model or source.
+ /// agent, model, source or budget (#717: daily or a special budget).
/// Offset of the viewer's local time - a "day" is the viewer's day.
public async Task> 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
};
diff --git a/src/ClawdDotNet.Core/Audit/AuditModels.cs b/src/ClawdDotNet.Core/Audit/AuditModels.cs
index a151cfd..b464f8e 100644
--- a/src/ClawdDotNet.Core/Audit/AuditModels.cs
+++ b/src/ClawdDotNet.Core/Audit/AuditModels.cs
@@ -127,6 +127,12 @@ public sealed record RunReceipt
/// chain. With it, a result can be followed back to the run that asked for it.
///
public string? CallerRunId { get; init; }
+
+ ///
+ /// #717: The task whose special budget the run drew on; null for a run on the daily
+ /// budget. Keeps the pot visible apart in the cost analysis.
+ ///
+ public string? BudgetPot { get; init; }
}
///
diff --git a/src/ClawdDotNet.Core/Audit/SqliteAuditRepository.cs b/src/ClawdDotNet.Core/Audit/SqliteAuditRepository.cs
index 9a44002..5f869f3 100644
--- a/src/ClawdDotNet.Core/Audit/SqliteAuditRepository.cs
+++ b/src/ClawdDotNet.Core/Audit/SqliteAuditRepository.cs
@@ -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> 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");
diff --git a/src/ClawdDotNet.Core/Budget/BudgetGuard.cs b/src/ClawdDotNet.Core/Budget/BudgetGuard.cs
index 5e252b2..7fba171 100644
--- a/src/ClawdDotNet.Core/Budget/BudgetGuard.cs
+++ b/src/ClawdDotNet.Core/Budget/BudgetGuard.cs
@@ -54,6 +54,9 @@ public sealed class BudgetGuard
private readonly Dictionary _reservedByAgent = new(StringComparer.OrdinalIgnoreCase);
private RunEstimate _reservedInstance;
+ /// #717: What runs in flight have reserved of each special budget.
+ private readonly Dictionary _reservedByPot = new(StringComparer.OrdinalIgnoreCase);
+
public BudgetGuard(IUsageRepository usage, Func? 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
}
}
+ ///
+ /// #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.
+ ///
+ public async Task 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;
+ }
+ }
+
/// Today's usage of an agent, or with an empty id of the whole instance.
public Task GetUsedTodayAsync(string agentId, CancellationToken ct)
- => _usage.GetDailyAsync(DateOnly.FromDateTime(_now()), agentId, ct);
+ => _usage.GetDailyForBudgetAsync(DateOnly.FromDateTime(_now()), agentId, ct);
/// What is currently reserved by runs in flight — for the agent, or with an
/// empty id for the whole instance.
@@ -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);
}
diff --git a/src/ClawdDotNet.Core/Budget/LargeTaskCheck.cs b/src/ClawdDotNet.Core/Budget/LargeTaskCheck.cs
new file mode 100644
index 0000000..b334732
--- /dev/null
+++ b/src/ClawdDotNet.Core/Budget/LargeTaskCheck.cs
@@ -0,0 +1,64 @@
+using System.Globalization;
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Tasks;
+
+namespace ClawdDotNet.Core.Budget;
+
+///
+/// #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.
+///
+/// Only on an estimate of this task. 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.
+///
+/// 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.
+///
+public sealed class LargeTaskCheck(
+ TaskCostEstimator estimator,
+ ITaskRepository tasks,
+ ISpecialBudgetRepository? pots,
+ Func> agents,
+ Func settings) : ILargeTaskCheck
+{
+ public async Task 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 IsFundedAsync(TaskItem task, CancellationToken ct)
+ => pots is not null && await SpecialBudgets.FindAsync(task.Id, tasks, pots, ct) is not null;
+
+ /// What the planning run is told — ahead of the task itself.
+ 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.
+ """;
+ }
+}
diff --git a/src/ClawdDotNet.Core/Budget/SpecialBudget.cs b/src/ClawdDotNet.Core/Budget/SpecialBudget.cs
new file mode 100644
index 0000000..1935dba
--- /dev/null
+++ b/src/ClawdDotNet.Core/Budget/SpecialBudget.cs
@@ -0,0 +1,133 @@
+using System.Globalization;
+using ClawdDotNet.Core.Storage;
+using ClawdDotNet.Core.Tasks;
+
+namespace ClawdDotNet.Core.Budget;
+
+///
+/// #717: A special budget — an amount for exactly one task and everything below it, approved
+/// by a human.
+///
+/// The one exception to "budgets stay at the agent" (concept 8a). 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.
+///
+public sealed record SpecialBudget
+{
+ /// The task at the top of the tree the pot is for.
+ public string TaskId { get; init; } = "";
+
+ public decimal AmountUsd { get; init; }
+ public string Reason { get; init; } = "";
+
+ /// The agent that asked for it.
+ public string RequestedBy { get; init; } = "";
+
+ public DateTime ApprovedAt { get; init; }
+}
+
+/// #717: Where special budgets are kept, and what they have used so far.
+public interface ISpecialBudgetRepository
+{
+ Task GetAsync(string taskId, CancellationToken ct);
+
+ /// Sets or replaces the pot of a task — a second approval replaces the amount.
+ Task SetAsync(SpecialBudget budget, CancellationToken ct);
+
+ Task> ListAsync(CancellationToken ct);
+
+ /// What runs have drawn on the pot, from the usage records marked with it.
+ Task SpentAsync(string taskId, CancellationToken ct);
+}
+
+public sealed class SqliteSpecialBudgetRepository(SqliteStorage storage) : ISpecialBudgetRepository
+{
+ public async Task 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> 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();
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ result.Add(Read(reader));
+ return result;
+ }
+
+ public async Task 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
+ };
+}
+
+/// #717: Finding the pot a task draws on — its own, or the nearest one above it.
+public static class SpecialBudgets
+{
+ /// A tree deeper than this is a mistake, not a plan; the walk stops there.
+ private const int MaxDepth = 32;
+
+ public static async Task FindAsync(
+ string taskId, ITaskRepository tasks, ISpecialBudgetRepository pots, CancellationToken ct)
+ {
+ var seen = new HashSet(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;
+ }
+}
diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
index 5329e2c..42503cd 100644
--- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs
+++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs
@@ -54,6 +54,12 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// #689: Escalations, conflicts and decisions; set by the host. Tools reach it through their context.
public Cases.ICaseDesk? CaseDesk { get; set; }
+ ///
+ /// #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.
+ ///
+ 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
///
/// 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 Pot zurück.
///
- 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);
}
+ ///
+ /// #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.
+ ///
+ 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;
+ }
+ }
+
+ /// #717: Pots already reported as used up — said once, not at every run.
+ private readonly System.Collections.Concurrent.ConcurrentDictionary _potUsedUp = new(StringComparer.OrdinalIgnoreCase);
+
/// Welche Budgetgrenze je Agent zuletzt an welchem Tag als Warnung gemeldet wurde.
private readonly System.Collections.Concurrent.ConcurrentDictionary _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.
///
- 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)
diff --git a/src/ClawdDotNet.Core/Staging/Werkzeugrechte.cs b/src/ClawdDotNet.Core/Staging/Werkzeugrechte.cs
index cd218e2..24d005a 100644
--- a/src/ClawdDotNet.Core/Staging/Werkzeugrechte.cs
+++ b/src/ClawdDotNet.Core/Staging/Werkzeugrechte.cs
@@ -93,12 +93,17 @@ public readonly record struct Freigabeeinstufung(StagingDecision Entscheidung, s
///
public static class FreigabeRegeln
{
- /// Was einen Agenten verändert. Bleibt ausnahmslos freigabepflichtig.
+ ///
+ /// 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.
+ ///
public static IReadOnlySet ImmerFreigabe { get; } = new HashSet(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");
diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
index 9ebb9fa..c1dd2e8 100644
--- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
+++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
@@ -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();
@@ -418,6 +427,13 @@ public sealed class SqliteStorage
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 = """
diff --git a/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs b/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs
index 7bf97a6..b929941 100644
--- a/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs
+++ b/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs
@@ -53,7 +53,14 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
_clock = clock ?? TimeProvider.System;
}
- public async Task DispatchAsync(TaskItem task, CancellationToken ct)
+ public Task DispatchAsync(TaskItem task, CancellationToken ct)
+ => DispatchAsync(task, null, ct);
+
+ ///
+ /// #717: With an instruction from the code ahead of the task — for a large task, that the
+ /// run plans instead of starting the work.
+ ///
+ public async Task 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;
diff --git a/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs b/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs
index 8d8f388..ebd7a70 100644
--- a/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs
+++ b/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs
@@ -100,6 +100,9 @@ public interface ITaskRepository
///
Task MarkFillAlertedAsync(string id, DateTime now, CancellationToken ct);
+ /// #717: Records that a large task had its planning run.
+ Task MarkLargePlannedAsync(string id, DateTime now, CancellationToken ct);
+
///
/// Reconciliation beim Start: verwaiste Claims (älter als )
/// lösen und die betroffenen Aufgaben von zurück
diff --git a/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs b/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs
index e3f8837..dac4a8e 100644
--- a/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs
+++ b/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs
@@ -271,6 +271,17 @@ public sealed class SqliteTaskRepository : ITaskRepository
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
+ public Task 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 ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
@@ -363,7 +374,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, "
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy, ParentId, OriginRunId, OriginTaskId, "
- + "FillBy, EstimateUsd, FillAlertedAt";
+ + "FillBy, EstimateUsd, FillAlertedAt, LargePlannedAt";
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
{
@@ -451,7 +462,8 @@ public sealed class SqliteTaskRepository : ITaskRepository
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))
+ FillAlertedAt = r.IsDBNull(32) ? null : Parse(r.GetString(32)),
+ LargePlannedAt = r.IsDBNull(33) ? null : Parse(r.GetString(33))
};
}
diff --git a/src/ClawdDotNet.Core/Tasks/TaskModels.cs b/src/ClawdDotNet.Core/Tasks/TaskModels.cs
index df16cc0..fe2f97c 100644
--- a/src/ClawdDotNet.Core/Tasks/TaskModels.cs
+++ b/src/ClawdDotNet.Core/Tasks/TaskModels.cs
@@ -274,6 +274,12 @@ public sealed record TaskItem
///
public DateTime? FillAlertedAt { get; init; }
+ ///
+ /// #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.
+ ///
+ public DateTime? LargePlannedAt { get; init; }
+
/// #717: One-time work with a deadline instead of an appointment.
public bool IsFillWork => FillBy is not null && !IsRecurring;
diff --git a/src/ClawdDotNet.Core/Tasks/TaskScanner.cs b/src/ClawdDotNet.Core/Tasks/TaskScanner.cs
index e9246c5..06b264d 100644
--- a/src/ClawdDotNet.Core/Tasks/TaskScanner.cs
+++ b/src/ClawdDotNet.Core/Tasks/TaskScanner.cs
@@ -33,6 +33,26 @@ public interface ITaskDispatcher
{
/// Führt die Aufgabe aus und meldet, wie der Lauf ausging.
Task DispatchAsync(TaskItem task, CancellationToken ct);
+
+ ///
+ /// #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.
+ ///
+ Task DispatchAsync(TaskItem task, string? instruction, CancellationToken ct)
+ => DispatchAsync(task, ct);
+}
+
+///
+/// #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.
+///
+public interface ILargeTaskCheck
+{
+ /// The instruction for the planning run, or null if the task can simply run.
+ Task PlanningInstructionAsync(TaskItem task, CancellationToken ct);
+
+ /// Whether the task's tree has a special budget — then, after planning, it runs.
+ Task IsFundedAsync(TaskItem task, CancellationToken ct);
}
///
@@ -170,6 +190,12 @@ public sealed class TaskScanner : IAsyncDisposable
private readonly Func _fillWarnBefore;
+ /// #717: Whether a task is too large to simply start.
+ private readonly ILargeTaskCheck? _largeCheck;
+
+ /// #717: Leaves a note on a task — why the scanner holds it.
+ private readonly Func? _noteOnTask;
+
/// #717: Fill work already reported as past its deadline — once per task.
private readonly HashSet _warnedOverdueFill = [];
@@ -205,8 +231,12 @@ public sealed class TaskScanner : IAsyncDisposable
ITaskTreeWatch? treeWatch = null,
IFillGate? fillGate = null,
Func? askAboutFill = null,
- Func? fillWarnBefore = null)
+ Func? fillWarnBefore = null,
+ ILargeTaskCheck? largeTaskCheck = null,
+ Func? noteOnTask = null)
{
+ _largeCheck = largeTaskCheck;
+ _noteOnTask = noteOnTask;
_treeWatch = treeWatch;
_fillGate = fillGate;
_askAboutFill = askAboutFill;
@@ -616,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)
{
@@ -656,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).
@@ -682,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);
+ }
}
+ ///
+ /// #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.
+ ///
+ private async Task 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;
+ }
+ }
+
+ ///
+ /// #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.
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+ }
+ }
+
+ ///
+ /// #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.
+ ///
+ 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);
+
///
/// #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
diff --git a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
index 3a2d622..9afdf05 100644
--- a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
+++ b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs
@@ -49,6 +49,9 @@ public sealed record AgentToolContext(
/// #689: The desk for escalations, conflicts and decisions. null outside the engine.
public Cases.ICaseDesk? Cases { get; init; }
+ /// #717: Special budgets — set up by an approved request_budget.
+ public Budget.ISpecialBudgetRepository? SpecialBudgets { get; init; }
+
/// #697: The run this call belongs to — for stamping where something came from.
public string? RunId { get; init; }
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
index 4da0349..8a589de 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
@@ -369,6 +369,7 @@
"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",
@@ -769,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",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
index 149c409..d563431 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
@@ -369,6 +369,7 @@
"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",
@@ -769,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",
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js
index 34bf775..b2ee4b3 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js
@@ -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,11 @@ 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.
@@ -247,10 +260,19 @@ function erstelleAufgabenKarte(task) {
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: formatiereKosten(task.estimateUsd) })
+ text: t("tasks.estimate", { usd: betrag(task.estimateUsd) })
}));
}
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/usage-timeline.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/usage-timeline.js
index d29d8d4..597d213 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/usage-timeline.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/usage-timeline.js
@@ -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);
}
diff --git a/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs b/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs
index 3a192a8..f9c34cd 100644
--- a/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs
+++ b/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs
@@ -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();
+ /// #717: A request that cannot be granted is refused before it reaches a human.
+ public string? Preflight(JsonElement input, AgentToolContext context)
+ => Get(input, "action") == "request_budget" ? CheckBudgetRequest(input, context, out _, out _) : null;
+
public async Task 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);
}
+ ///
+ /// #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.
+ ///
+ 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 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}");
diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs
index b5c5a63..58d3ba3 100644
--- a/src/ClawdDotNet.Web/Contracts/Dtos.cs
+++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs
@@ -135,6 +135,9 @@ public sealed record TaskDto(
decimal? EstimateUsd = null,
DateTime? FillAlertedAt = null);
+/// #717: A special budget — the amount a human approved for a task tree, and what it used.
+public sealed record SpecialBudgetDto(string TaskId, decimal AmountUsd, decimal SpentUsd, string Reason, string RequestedBy, DateTime ApprovedAt);
+
///
/// #716: Runs of this task the budget refused — only present while something is open
/// (misses since the last run that took place, a pending catch-up or a deferral).
diff --git a/src/ClawdDotNet.Web/Endpoints/AnalyticsEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/AnalyticsEndpoints.cs
index 64ed383..90de1dc 100644
--- a/src/ClawdDotNet.Web/Endpoints/AnalyticsEndpoints.cs
+++ b/src/ClawdDotNet.Web/Endpoints/AnalyticsEndpoints.cs
@@ -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,
diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
index b74eed2..eda8859 100644
--- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
+++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
@@ -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());
+ var list = new List();
+ 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)
diff --git a/tests/ClawdDotNet.Core.Tests/Budget/SpecialBudgetTests.cs b/tests/ClawdDotNet.Core.Tests/Budget/SpecialBudgetTests.cs
new file mode 100644
index 0000000..f6febbf
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Budget/SpecialBudgetTests.cs
@@ -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;
+
+///
+/// #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.
+///
+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 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? 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();
+ 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();
+
+ 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 onPlanning) : ITaskDispatcher
+ {
+ public List Instructions { get; } = [];
+ public List Started { get; } = [];
+
+ public Task DispatchAsync(TaskItem task, CancellationToken ct)
+ {
+ Started.Add(task.Id);
+ return Task.FromResult(TaskLauf.Erledigt);
+ }
+
+ public async Task 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;
+ }
+ }
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/EngineFixture.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/EngineFixture.cs
index cbceec1..9066466 100644
--- a/tests/ClawdDotNet.Core.Tests/Infrastructure/EngineFixture.cs
+++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/EngineFixture.cs
@@ -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)
diff --git a/tests/ClawdDotNet.Tools.Tests/Taskboard/BudgetRequestTests.cs b/tests/ClawdDotNet.Tools.Tests/Taskboard/BudgetRequestTests.cs
new file mode 100644
index 0000000..8af77d5
--- /dev/null
+++ b/tests/ClawdDotNet.Tools.Tests/Taskboard/BudgetRequestTests.cs
@@ -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;
+
+/// #717: Asking for a special budget — the agent proposes, a human always decides.
+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(), 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);
+ }
+}