Compare commits
3
Commits
25f74d9ad6
...
b5f65b8cf7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5f65b8cf7 | ||
|
|
67649559f5 | ||
|
|
183e922278 |
@@ -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) —
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user