Entscheidungen aus dem Testbetrieb umgesetzt (#572): #577 #579 #581 #589 #193 #205 #594 #595 #597 #598 #599 #588

- Engine: leere Antworten nachfassen, Aufgaben enden mit Ergebniszeile (EmptyResponse/Incomplete),
  gleiche Fehlschlaege erkennen (RepeatedFailure), Zeitzeile vor Chatnachrichten,
  Werkzeugergebnisse gerahmt, Vorpruefung vor dem Staging, Belegkosten wie RunUsage
- Staging: gescheiterte Freigaben als Failed; AgentEditor-Aenderungen freigabepflichtig,
  Selbstbearbeitung gesperrt (allowSelfEdit)
- Taskboard: Termine beim Anlegen pruefen, naechster/letzter Lauf, pausieren, Sofortlauf,
  Kostenprognose, Hinweis auf wachsenden Kontext; Jobs-Reiter und /api/jobs entfernt
- Release: Instanzmanager samt Weboberflaeche im Paket, setup.json fragt manager.json ab,
  Token nicht mehr ins systemd-Journal
Version 0.1.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-15 11:23:10 +02:00
co-authored by Claude Opus 5
parent 8ab9d99abe
commit 0b6ea73ec1
42 changed files with 2646 additions and 512 deletions
@@ -0,0 +1,222 @@
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Tasks;
namespace ClawdDotNet.Core.Accounting;
/// <summary>Worauf die Kosten je Lauf einer Aufgabe beruhen.</summary>
public enum PrognoseGrundlage
{
/// <summary>Mittelwert der letzten Laeufe dieser Aufgabe.</summary>
Laeufe,
/// <summary>Noch kein Lauf mit Preis - Mittelwert anderer Aufgaben desselben Agenten.</summary>
Agent,
/// <summary>Weder noch - grobe Schaetzung aus Promptlaenge und Modellpreis.</summary>
Schaetzung,
/// <summary>Nichts, woraus sich rechnen liesse (z.B. Modellpreis unbekannt).</summary>
Unbekannt
}
/// <summary>Die Prognose einer einzelnen Aufgabe.</summary>
public sealed record AufgabenPrognose(
string TaskId,
string Title,
string AgentId,
string Model,
double LaeufeProTag,
bool EinmaligOffen,
decimal? KostenJeLauf,
PrognoseGrundlage Grundlage,
int Stichprobe,
decimal KostenProTag);
/// <summary>Die Prognose der ganzen Instanz.</summary>
public sealed record InstanzPrognose(
IReadOnlyList<AufgabenPrognose> Aufgaben,
decimal AufgabenProTag,
decimal SonstigeProTag,
decimal SummeProTag,
decimal SummeProMonat,
decimal EinmaligOffen,
int OhneKostenbasis);
/// <summary>
/// Rechnet aus Terminen und bisherigen Laeufen hoch, was eine Instanz kuenftig kostet (#595).
///
/// <para>Drei Stufen je Aufgabe, von belastbar zu grob: der Mittelwert ihrer letzten Laeufe;
/// ohne solche der Mittelwert anderer Aufgaben desselben Agenten; ohne beides eine Schaetzung
/// aus Promptlaenge und Modellpreis. Welche Stufe gegriffen hat, steht an jeder Zeile - eine
/// Prognose, die nicht sagt, worauf sie beruht, ist eine Zahl ohne Wert.</para>
///
/// <para>Die Schaetzung rechnet mit Listenpreis ohne Cache-Rabatt und liegt damit eher zu hoch.
/// Laeufe ausserhalb von Aufgaben (Chat, Nachrichten zwischen Agenten) gehen als Tagesmittel
/// der letzten sieben Tage ein.</para>
///
/// <para>Rein und ohne Zugriffe: Was sie braucht, bekommt sie uebergeben. So bleibt sie
/// testbar, und die Oberflaechen entscheiden, woher die Daten kommen.</para>
/// </summary>
public static class KostenPrognose
{
public const int Stichprobe = 10;
public const int TageRueckblick = 7;
public const int TageJeMonat = 30;
// Annahmen fuer die Schaetzung vor dem ersten Lauf.
internal const int AnnahmeSchritte = 3;
internal const int AnnahmeWerkzeugTokens = 1_500;
internal const int AnnahmeAntwortTokensJeSchritt = 400;
public static InstanzPrognose Berechne(
IReadOnlyList<TaskItem> aufgaben,
IReadOnlyDictionary<string, IReadOnlyList<RunReceipt>> belegeJeAufgabe,
IReadOnlyList<RunUsage> verbrauch,
IReadOnlyList<AgentConfig> agenten,
Func<string, int, int, CostEstimate>? schaetzen,
DateTime jetztUtc)
{
jetztUtc = DateTime.SpecifyKind(jetztUtc, DateTimeKind.Utc);
var seit = jetztUtc.AddDays(-TageRueckblick);
var agentNachId = agenten
.GroupBy(a => a.AgentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var alleBelege = belegeJeAufgabe.Values.SelectMany(b => b).ToList();
var bekannteJeAgent = alleBelege
.Where(b => b.CostIsKnown)
.GroupBy(b => b.AgentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => g.OrderByDescending(b => Utc(b.OccurredAt)).Take(Stichprobe).ToList(),
StringComparer.OrdinalIgnoreCase);
var zeilen = new List<AufgabenPrognose>();
foreach (var aufgabe in aufgaben)
{
if (aufgabe.Status is not (TaskItemStatus.Todo or TaskItemStatus.InProgress))
continue; // pausiert, erledigt, blockiert: kostet vorerst nichts
if (TaskAssignee.KindOf(aufgabe.Assignee) == TaskAssigneeKind.Human)
continue;
var agentId = TaskAssignee.AgentId(aufgabe.Assignee);
if (string.IsNullOrWhiteSpace(agentId) && agenten.Count == 1)
agentId = agenten[0].AgentId;
agentNachId.TryGetValue(agentId, out var agent);
var belege = belegeJeAufgabe.GetValueOrDefault(aufgabe.Id) ?? [];
zeilen.Add(aufgabe.Type == TaskItemType.ToolJob
? WerkzeugJob(aufgabe, agentId, agent, belege, seit)
: Arbeit(aufgabe, agentId, agent, belege, bekannteJeAgent, schaetzen, jetztUtc));
}
var aufgabenProTag = zeilen.Sum(z => z.KostenProTag);
// Was nicht zu einer Aufgabe gehoert: Gesamtverbrauch der letzten Tage minus die
// Belege der Aufgaben im selben Zeitraum. Nie negativ - aeltere Belege koennen
// hoeher liegen als der Verbrauch (#599).
var verbrauchImFenster = verbrauch
.Where(u => u.CostIsKnown && Utc(u.OccurredAt) >= seit)
.Sum(u => u.CostUsd);
var aufgabenImFenster = alleBelege
.Where(b => b.CostIsKnown && Utc(b.OccurredAt) >= seit)
.Sum(b => b.CostUsd);
var sonstigeProTag = Math.Max(0m, (verbrauchImFenster - aufgabenImFenster) / TageRueckblick);
var summeProTag = aufgabenProTag + sonstigeProTag;
return new InstanzPrognose(
zeilen.OrderByDescending(z => z.KostenProTag).ThenBy(z => z.Title).ToList(),
Runde(aufgabenProTag),
Runde(sonstigeProTag),
Runde(summeProTag),
Runde(summeProTag * TageJeMonat),
Runde(zeilen.Where(z => z.EinmaligOffen).Sum(z => z.KostenJeLauf ?? 0m)),
zeilen.Count(z => z.Grundlage == PrognoseGrundlage.Unbekannt && (z.LaeufeProTag > 0 || z.EinmaligOffen)));
}
private static AufgabenPrognose Arbeit(
TaskItem aufgabe, string agentId, AgentConfig? agent, IReadOnlyList<RunReceipt> belege,
IReadOnlyDictionary<string, List<RunReceipt>> bekannteJeAgent,
Func<string, int, int, CostEstimate>? schaetzen, DateTime jetztUtc)
{
var laeufeProTag = aufgabe.IsRecurring ? TaskSchedule.RunsPerDay(aufgabe, jetztUtc) : 0;
var einmaligOffen = !aufgabe.IsRecurring && aufgabe.LastOccurrence is null;
var eigene = belege
.Where(b => b.CostIsKnown)
.OrderByDescending(b => Utc(b.OccurredAt))
.Take(Stichprobe)
.ToList();
decimal? jeLauf;
PrognoseGrundlage grundlage;
int stichprobe;
if (eigene.Count > 0)
{
(jeLauf, grundlage, stichprobe) = (eigene.Average(b => b.CostUsd), PrognoseGrundlage.Laeufe, eigene.Count);
}
else if (bekannteJeAgent.TryGetValue(agentId, out var desAgenten) && desAgenten.Count > 0)
{
(jeLauf, grundlage, stichprobe) = (desAgenten.Average(b => b.CostUsd), PrognoseGrundlage.Agent, desAgenten.Count);
}
else if (agent is not null && schaetzen is not null
&& schaetzen(agent.Model, GeschaetztePromptTokens(aufgabe, agent), AnnahmeSchritte * AnnahmeAntwortTokensJeSchritt)
is { IsKnown: true } schaetzung)
{
(jeLauf, grundlage, stichprobe) = (schaetzung.Usd, PrognoseGrundlage.Schaetzung, 0);
}
else
{
(jeLauf, grundlage, stichprobe) = (null, PrognoseGrundlage.Unbekannt, 0);
}
var proTag = jeLauf is { } k ? k * (decimal)laeufeProTag : 0m;
return new AufgabenPrognose(
aufgabe.Id, aufgabe.Title, agentId, agent?.Model ?? "",
Math.Round(laeufeProTag, 2), einmaligOffen,
jeLauf is { } j ? Runde(j) : null, grundlage, stichprobe, Runde(proTag));
}
/// <summary>
/// Ein Werkzeug-Job tickt oft, weckt den Agenten aber nur, wenn es etwas gibt. Laeufe
/// je Termin mal Kosten je Lauf waere hier falsch - gezaehlt wird, was in den letzten
/// Tagen tatsaechlich an Belegen entstand.
/// </summary>
private static AufgabenPrognose WerkzeugJob(
TaskItem aufgabe, string agentId, AgentConfig? agent, IReadOnlyList<RunReceipt> belege, DateTime seit)
{
var imFenster = belege.Where(b => b.CostIsKnown && Utc(b.OccurredAt) >= seit).ToList();
decimal? jeLauf = imFenster.Count > 0 ? imFenster.Average(b => b.CostUsd) : null;
return new AufgabenPrognose(
aufgabe.Id, aufgabe.Title, agentId, agent?.Model ?? "",
Math.Round(imFenster.Count / (double)TageRueckblick, 2), false,
jeLauf is { } j ? Runde(j) : null,
imFenster.Count > 0 ? PrognoseGrundlage.Laeufe : PrognoseGrundlage.Unbekannt,
imFenster.Count,
Runde(imFenster.Sum(b => b.CostUsd) / TageRueckblick));
}
/// <summary>Kontext je Schritt grob aus Zeichen/4, dazu die Werkzeugdefinitionen, ueber die angenommenen Schritte.</summary>
internal static int GeschaetztePromptTokens(TaskItem aufgabe, AgentConfig agent)
{
var zeichen = agent.FullSystemPrompt.Length + aufgabe.Title.Length + aufgabe.Body.Length + aufgabe.Acceptance.Length + 500;
var jeSchritt = zeichen / 4 + AnnahmeWerkzeugTokens;
return jeSchritt * AnnahmeSchritte;
}
private static DateTime Utc(DateTime wert)
=> wert.Kind == DateTimeKind.Utc ? wert : wert.ToUniversalTime();
private static decimal Runde(decimal wert) => Math.Round(wert, 4);
}