Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc11961805 | ||
|
|
f627918729 | ||
|
|
d587dc3f20 | ||
|
|
991a901cab | ||
|
|
0f49e04261 | ||
|
|
fe0eb09d7c | ||
|
|
32383119da | ||
|
|
9cf9a2cfc2 | ||
|
|
8a83a67fb4 | ||
|
|
5f3a85595b | ||
|
|
b53396a754 | ||
|
|
4415b26e7d | ||
|
|
30a5aae825 |
@@ -24,3 +24,6 @@
|
||||
*.jpg binary
|
||||
*.zip binary
|
||||
*.dll binary
|
||||
|
||||
# Vendored third-party files stay byte for byte as published (checksums in vendor/README.md)
|
||||
src/ClawdDotNet.Manager.Web/wwwroot/vendor/** -text
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Organisation — 50 bis 100 Agenten geordnet zusammenarbeiten lassen
|
||||
|
||||
*Stand: 24. September 2026 (ergänzt um 7a, 9a, 10a, Einheiten ohne eigene Konfiguration, Budgetende). Konzept, noch nicht gebaut.*
|
||||
*Stand: 24. September 2026 (ergänzt um 7a, 9a, 10a, Einstellungen an der Einheit, Budgetende, Füllarbeit). Konzept, noch nicht gebaut.*
|
||||
|
||||
> Herkunft: Richtungsentscheidung des Nutzers am 2026-09-21. Die bisherige Regel im
|
||||
> [InstanceSetupGuide](InstanceSetupGuide.md) („mehr als 5 Agenten kritisch
|
||||
@@ -123,37 +123,46 @@ Instanz „Onlinehandel"
|
||||
| **Freigaben** | ein Mensch | die Leitung innerhalb ihrer Grenzen, der Mensch für alles darüber (Geld, Außenwirkung) |
|
||||
| **Parallelität** | unbegrenzt | je Einheit und je Instanz eine Zahl gleichzeitiger Läufe, der Rest wartet |
|
||||
|
||||
### Eine Einheit hat keine eigene Konfiguration
|
||||
### Einstellungen an der Einheit: nie zwei Werte, die sich übertrumpfen
|
||||
|
||||
*Festgelegt am 2026-09-24 durch den Nutzer.*
|
||||
*Festgelegt am 2026-09-24 durch den Nutzer, in zwei Schritten.*
|
||||
|
||||
Was man „für die ganze Einheit" einstellt, wird **nicht an der Einheit gespeichert,
|
||||
sondern gesammelt in jeden ihrer Agenten geschrieben**. Wer für das Marketing das
|
||||
Tagesbudget je Agent von 5 auf 10 $ hebt, ändert zehn Agentenkonfigurationen auf einmal.
|
||||
Eine Einheiten-Einstellung, die im Hintergrund gilt, gibt es nicht.
|
||||
Einstellungen lassen sich an der Einheit speichern und bearbeiten. **Aber es gibt nie
|
||||
zwei Werte für dieselbe Sache, von denen einer den anderen zur Laufzeit übertrumpft.**
|
||||
Daraus folgen genau zwei Arten von Einheiten-Einstellungen:
|
||||
|
||||
Der Gewinn: **Was am Agenten steht, gilt.** Man muss nie eine Kette von Einheiten
|
||||
hinaufrechnen, um zu wissen, welche Grenze ein Agent wirklich hat. Und ein Umzug ändert
|
||||
nichts, was man nicht sieht.
|
||||
**1. Sammelwerte, die in die Agenten geschrieben werden.** Alles, was es auch am Agenten
|
||||
gibt: Tagesbudget, Modell, Verhalten beim Budgetende (#716). Wer für das Marketing das
|
||||
Tagesbudget setzt, schreibt den Wert in jeden Agenten darunter. Die Einheit merkt sich den
|
||||
Wert nur als **Vorgabe für Agenten, die neu hinzukommen**, nie als Grenze, die im
|
||||
Hintergrund gilt. Danach steht am Agenten, was gilt, und nur dort.
|
||||
|
||||
Was die Sammeländerung braucht: eine Vorschau, welche Agenten sich wie ändern, einen
|
||||
Audit-Eintrag je Agent und eine Entscheidung, was mit Agenten geschieht, die schon einen
|
||||
abweichenden Wert haben. Vorschlag: Die Vorschau zeigt sie gesondert, und der Mensch
|
||||
entscheidet, ob sie mit überschrieben werden.
|
||||
- **Vorschau** vor dem Schreiben: welche Agenten sich wie ändern, Audit-Eintrag je Agent.
|
||||
- **Warnung bei Ausreißern:** Weicht der bisherige Wert eines Agenten massiv von denen
|
||||
seiner Kollegen ab, wird er in der Vorschau hervorgehoben und ist standardmäßig
|
||||
**nicht** angehakt. Gemeint ist der Spezialist, der bewusst viele Tokens verbraucht und
|
||||
sonst mit dem Wert seiner Kollegen kaputtkonfiguriert würde. Der Mensch entscheidet je
|
||||
Ausreißer, ob er mit überschrieben wird.
|
||||
- Die Vorschau bietet zwei Rechenwege: **ein Wert je Agent** („jeder bekommt 10 $") oder
|
||||
**ein Gesamtbetrag, verteilt** („das Marketing bekommt 100 $"). Beim Verteilen
|
||||
vorgeschlagen: im Verhältnis der bisherigen Werte, damit ein Spezialist seinen
|
||||
größeren Anteil behält.
|
||||
|
||||
**Noch zu klären, weil es drei Stellen dieses Konzepts berührt:**
|
||||
**2. Regeln, die es nur an der Einheit gibt.** Sie haben kein Gegenstück am Agenten und
|
||||
können deshalb mit nichts konkurrieren:
|
||||
|
||||
- **Rechte.** Die Tabelle oben sagt „vererbt von der Einheit; was die Einheit nicht darf,
|
||||
kann ein Mitglied nicht bekommen". Das ist eine Obergrenze an der Einheit, also genau
|
||||
eine gespeicherte Einheiten-Konfiguration. Mit Sammeländerung statt Vererbung entfällt
|
||||
diese Obergrenze: Einem einzelnen Agenten ließe sich später mehr geben als seinen
|
||||
Kollegen. Offen ist, ob das hingenommen wird oder ob die Obergrenze die eine Ausnahme ist.
|
||||
- **Parallelität.** Eine Zahl gleichzeitiger Läufe *je Einheit* lässt sich nicht auf
|
||||
Agenten verteilen: Jeder Agent läuft ohnehin nur einmal gleichzeitig. Entweder sie
|
||||
bleibt instanzweit (so ist #682 gebaut), oder sie ist eine zweite Ausnahme.
|
||||
- **Grenzen der Leitung** (Abschnitt 12) standen bisher „an der Einheit, nicht an der
|
||||
Person der Leitung". Nach dieser Festlegung stehen sie am Leitungsagenten. Beim
|
||||
Wechsel der Leitung müssen sie dann mit umziehen.
|
||||
- **Rechte-Obergrenze.** „Was die Einheit nicht darf, kann ein Mitglied nicht bekommen"
|
||||
bleibt, weil sie eine Sicherheitsgrenze ist. Sie wird **beim Schreiben** geprüft, nicht
|
||||
zur Laufzeit darübergelegt: Eine Rechteänderung am Agenten, die über die Obergrenze
|
||||
hinausginge, wird abgewiesen. Senkt man die Obergrenze, zeigt die Vorschau, welchen
|
||||
Agenten dadurch Rechte entzogen werden, und schreibt es in sie hinein. Damit gilt auch
|
||||
hier: Was am Agenten steht, gilt.
|
||||
- **Parallelität je Einheit** (#685). Ein Agent läuft ohnehin nur einmal gleichzeitig,
|
||||
die Zahl gehört also nur der Einheit. Bis #685 gebaut ist, gilt sie instanzweit
|
||||
(`maxParallelTaskRuns`, #682).
|
||||
|
||||
**Grenzen der Leitung** (Abschnitt 12) sind ein Sammelwert: Sie stehen am
|
||||
Leitungsagenten. Beim Wechsel der Leitung zeigt die Vorschau, was mit umzieht.
|
||||
|
||||
### Beliebig tief, und beweglich
|
||||
|
||||
@@ -945,9 +954,14 @@ scheitert:
|
||||
| `carry_over` | wird mitgenommen | einmalige Aufgaben: echte Arbeit, die nicht verschwinden darf |
|
||||
|
||||
Dazu gilt: **Der neue Tag hat Vorfahrt.** Nachholläufe kommen nach den regulär fälligen,
|
||||
unterhalb jeder Priorität, und dürfen höchstens einen Anteil des neuen Tagesbudgets
|
||||
verbrauchen (Vorgabe etwa 25 %). Ohne diese Regel schiebt ein einziger überzogener Tag
|
||||
seinen Ausfall Tag für Tag vor sich her, statt ihn aufzulösen. Eine einmalige Aufgabe, die
|
||||
unterhalb jeder Priorität, und sie nutzen nur den **Spielraum**, den der Rest des Tages
|
||||
nicht braucht: Grenze minus Verbrauch, minus die Reservierung laufender Läufe, minus der
|
||||
Bedarf der noch geplanten Läufe bis Mitternacht, minus 10 % Reserve. Ohne diese Regel
|
||||
schiebt ein einziger überzogener Tag seinen Ausfall Tag für Tag vor sich her, statt ihn
|
||||
aufzulösen. *Beim Bau am 2026-09-24 geändert:* Das Konzept sah hier zunächst einen
|
||||
pauschalen Anteil von 25 % vor. Ein fester Anteil ist an einem vollen Tag zu viel und an
|
||||
einem ruhigen unnötig wenig; der Spielraum folgt dem tatsächlichen Plan, und dieselbe
|
||||
Rechnung trägt später die Füllarbeit. Eine einmalige Aufgabe, die
|
||||
nach einigen Tagen immer noch nicht drankam, geht an den Menschen: Was nie läuft, darf
|
||||
nicht unbemerkt liegen bleiben.
|
||||
|
||||
@@ -966,6 +980,42 @@ prüft jeder Lauf das Budget einzeln vor dem Start, und bis zu `maxParallelTaskR
|
||||
können es gemeinsam überschreiten. Erst mit einer Reservierung vor dem Lauf ist der
|
||||
Ausfall vorhersagbar statt eine Frage, welcher Lauf zuerst zugreift.
|
||||
|
||||
### Füllarbeit und Großaufgaben
|
||||
|
||||
*Ergänzt am 2026-09-24 auf Vorschlag des Nutzers. Item #717.*
|
||||
|
||||
Der vorige Abschnitt regelt, was liegen bleibt, wenn das Budget nicht reicht. Die
|
||||
Kehrseite: An den meisten Tagen bleibt Budget übrig, und das verfällt.
|
||||
|
||||
**Füllarbeit** ist Arbeit, die umfangreich, aber nicht eilig ist. Ob sie heute, morgen
|
||||
oder übermorgen läuft, ist gleich. Sie bekommt statt eines Termins eine **Frist
|
||||
„spätestens bis"** und läuft, wenn Platz ist. Rechenzentren machen das seit Jahrzehnten
|
||||
so und nennen es *Backfilling*. Ob Platz ist, entscheidet der Code:
|
||||
|
||||
- Keine regulär fällige Aufgabe des Agenten wartet.
|
||||
- Das Restbudget des Tages, abzüglich der Prognose für die noch geplanten Läufe (#608) und
|
||||
eines Sicherheitsaufschlags, reicht für die Schätzung der Füllarbeit.
|
||||
- Bevorzugt spät am Tag, weil die Prognose dann am wenigsten Unsicherheit hat.
|
||||
|
||||
Rückt die Frist näher und es fand sich nie Platz, wird der Mensch gefragt: Budget
|
||||
erhöhen oder Frist verschieben. Eine Füllarbeit überschreitet ihre Frist nie still.
|
||||
|
||||
**Großaufgaben** sind Aufgaben, deren Schätzung einen großen Teil des Tagesbudgets
|
||||
übersteigt (Vorgabe etwa 50 %). Sie werden nicht einfach angefangen. Der Agent schlägt
|
||||
einen von zwei Wegen vor:
|
||||
|
||||
1. **Teilen.** Unteraufgaben, jede ein eigener Lauf, verbunden über die Elternaufgabe
|
||||
(Zielkette, Abschnitt 9a). Die Teile können dann Füllarbeit sein.
|
||||
2. **Sonderbudget.** Ein Betrag für genau diese Aufgabe samt Unteraufgaben, vom Menschen
|
||||
freigegeben. Es ist ein eigener Topf: Es hebt die Tagesgrenze des Agenten nicht an und
|
||||
ist in der Kostenanalyse gesondert sichtbar. Damit ist es die einzige Ausnahme von
|
||||
„Budgets bleiben am Agenten", gebunden an eine Aufgabe und nie still.
|
||||
|
||||
**Ehrlich schätzen.** Kosten lassen sich vorab nicht genau wissen, und Modelle schätzen
|
||||
ihren eigenen Aufwand schlecht. Wo es Verlauf gibt, schätzt der Code aus dem Verlauf.
|
||||
Sonst schätzt der Agent, und die Zahl ist als Schätzung gekennzeichnet. Nach dem ersten
|
||||
Teillauf ersetzt der gemessene Wert die Schätzung.
|
||||
|
||||
### Was die Auswertung dafür braucht
|
||||
|
||||
- **Die Einheit als Dimension** in Filtern und Zeitverlauf der Verbrauchsansicht (#660) —
|
||||
@@ -1295,8 +1345,8 @@ Gegenlesen am 2026-09-22 als zu grob aufgefallen:
|
||||
innerhalb ihrer Grenzen liegt", ohne zu sagen, woraus eine Grenze besteht. Vorschlag:
|
||||
Betrag je Vorgang, Betrag je Tag, erlaubte Werkzeugklassen, Außenwirkung ja/nein — und
|
||||
alles davon an der Einheit, nicht an der Person der Leitung (Stufe 8). *Überholt am
|
||||
2026-09-24: Einheiten speichern keine eigene Konfiguration (Abschnitt 3), die Grenzen
|
||||
stehen damit am Leitungsagenten.*
|
||||
2026-09-24: Grenzen sind ein Sammelwert und stehen am Leitungsagenten (Abschnitt 3,
|
||||
„Einstellungen an der Einheit").*
|
||||
- **Wie wird ein Beschluss wirksam?** „Bindend für alles unterhalb" ist ein Wort ohne
|
||||
Maschinerie, solange niemand nachsieht. Vorschlag: Das Eskalationswerkzeug sucht von
|
||||
sich aus, bevor es einen Vorgang anlegt — genau wie `task_find` vor `task_create`
|
||||
@@ -1347,6 +1397,7 @@ nur nicht selbst an.
|
||||
| #687 | Freigabe durch die Leitung innerhalb von Grenzen |
|
||||
| #688 | Organigramm in Oberfläche und WebUI |
|
||||
| #716 | Was liegen bleibt, wenn das Budget endet (Ausfall, Nachholen, Reservierung) |
|
||||
| #717 | Füllarbeit und Großaufgaben: ungenutztes Budget füllen, teilen oder Sonderbudget |
|
||||
| #711 | Lagebild (Teamübersicht): wer arbeitet, wer spricht mit wem, wo klemmt es |
|
||||
| #712 | Fremde Laufzeiten: Claude Code, Codex, Befehl, HTTP |
|
||||
| #713 | ClawdDotNet als MCP-Server für fremde Laufzeiten |
|
||||
|
||||
@@ -3,6 +3,7 @@ using ClawdDotNet.App.Settings;
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
using ClawdDotNet.Core.Api;
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Budget;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Logging;
|
||||
@@ -112,6 +113,9 @@ public sealed class AppHost : IAsyncDisposable
|
||||
Board, AuditRepository, Scanner, Usage, Pricing, () => Instance.Agents,
|
||||
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
|
||||
|
||||
/// <summary>#711: The team overview. Stateless, like <see cref="TaskAdmin"/>.</summary>
|
||||
public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents);
|
||||
|
||||
public TelegramClientManager? TelegramClient { get; private init; }
|
||||
public OpenRouterStatusService? Status { get; private init; }
|
||||
|
||||
@@ -725,8 +729,24 @@ public sealed class AppHost : IAsyncDisposable
|
||||
var dispatcher = new EngineTaskDispatcher(
|
||||
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory);
|
||||
|
||||
var headroom = engine.BudgetGuard is { } guard
|
||||
? new BudgetHeadroom(guard, () => instance.Agents, () => engine.InstanceBudget)
|
||||
: null;
|
||||
|
||||
var scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory,
|
||||
maxParallelRuns: instance.MaxParallelTaskRuns);
|
||||
maxParallelRuns: instance.MaxParallelTaskRuns,
|
||||
budgetMissPolicy: task =>
|
||||
{
|
||||
// #716: The agent's rule. Bare '@new' names no agent — then the default applies.
|
||||
var agentId = TaskAssignee.AgentId(task.Assignee);
|
||||
return instance.Agents
|
||||
.FirstOrDefault(a => string.Equals(a.AgentId, agentId, StringComparison.OrdinalIgnoreCase))
|
||||
?.Budget.MissPolicy ?? BudgetMissPolicy.Expire;
|
||||
},
|
||||
// #716: Late work waits for headroom the rest of the day does not need, and a
|
||||
// day the budget will not last is named while there is still time to act.
|
||||
lateRunGate: headroom,
|
||||
budgetOutlook: headroom);
|
||||
|
||||
// Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
|
||||
_ = Task.Run(async () =>
|
||||
|
||||
@@ -187,7 +187,8 @@ public sealed class TaskAdministration(
|
||||
if (board is null)
|
||||
return null;
|
||||
|
||||
var aufgaben = await board.ListAsync(new TaskQuery { Limit = 500 }, ct);
|
||||
// #716: ungekappt - vorher fehlten ab 500 Aufgaben die übrigen in der Prognose.
|
||||
var aufgaben = await board.ListAllAsync(ct);
|
||||
|
||||
var belege = new Dictionary<string, IReadOnlyList<RunReceipt>>(StringComparer.Ordinal);
|
||||
if (audit is not null)
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
using ClawdDotNet.Core.Activity;
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>What an agent is doing, as the team overview shows it (#711).</summary>
|
||||
public enum TeamState
|
||||
{
|
||||
/// <summary>Nothing running, nothing open, the last run went fine (or there was none).</summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>At least one run in progress.</summary>
|
||||
Working,
|
||||
|
||||
/// <summary>A run has taken much longer than this agent's runs usually take.</summary>
|
||||
Stuck,
|
||||
|
||||
/// <summary>Tool calls of this agent wait for a human decision.</summary>
|
||||
WaitingForApproval,
|
||||
|
||||
/// <summary>The last run failed, hit a limit or found the target busy.</summary>
|
||||
Problem,
|
||||
|
||||
/// <summary>The last run was refused because the budget is used up.</summary>
|
||||
BudgetExhausted
|
||||
}
|
||||
|
||||
/// <summary>One agent in the team overview.</summary>
|
||||
public sealed record TeamMember(
|
||||
string AgentId,
|
||||
string DisplayName,
|
||||
string Model,
|
||||
TeamState State,
|
||||
IReadOnlyList<ActiveRun> Runs,
|
||||
int PendingApprovals,
|
||||
string? LastRunStatus,
|
||||
DateTime? LastRunAtUtc,
|
||||
string? LastRunResult,
|
||||
long TypicalRunMs);
|
||||
|
||||
/// <summary>The whole picture at one moment: every agent, and who reached whom recently.</summary>
|
||||
public sealed record TeamSnapshot(IReadOnlyList<TeamMember> Members, IReadOnlyList<AgentLink> RecentLinks, DateTime AtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// #711: The team overview — who is working, who reached whom, where something is stuck.
|
||||
///
|
||||
/// <para><b>Detecting is code, interpreting is model</b> (Organisation-Konzept, 10a). Every
|
||||
/// state here comes from a fact the instance recorded: a run in progress, an open staged
|
||||
/// call, the status of the last receipt. Nothing is judged by a model, so the picture is
|
||||
/// the same for everyone who looks and costs nothing to draw.</para>
|
||||
///
|
||||
/// <para><b>Order of states.</b> When several apply, the one that needs someone first wins:
|
||||
/// stuck, then waiting for approval, then working; a problem or an exhausted budget only
|
||||
/// shows while nothing new is running — a new run is the attempt to get past it.</para>
|
||||
/// </summary>
|
||||
public sealed class TeamOverview(
|
||||
AgentEngine? engine,
|
||||
StagingService? staging,
|
||||
IAuditRepository? audit,
|
||||
IUsageRepository? usage,
|
||||
Func<IReadOnlyList<AgentConfig>> agents,
|
||||
TimeProvider? clock = null)
|
||||
{
|
||||
/// <summary>How far back the last receipt of each agent is looked for.</summary>
|
||||
public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3);
|
||||
|
||||
/// <summary>How long recent links stay in the snapshot — the overview fades them out.</summary>
|
||||
public static readonly TimeSpan LinkWindow = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>A run counts as stuck after this many times the agent's typical duration…</summary>
|
||||
public const int StuckFactor = 3;
|
||||
|
||||
/// <summary>…but never before this — short runs vary a lot in relative terms.</summary>
|
||||
public static readonly TimeSpan StuckFloor = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>Without history, a run counts as stuck after this long.</summary>
|
||||
public static readonly TimeSpan StuckWithoutHistory = TimeSpan.FromMinutes(15);
|
||||
|
||||
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
|
||||
|
||||
public async Task<TeamSnapshot> SnapshotAsync(CancellationToken ct)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var configured = agents();
|
||||
|
||||
var active = engine?.Activity.ActiveRuns() ?? [];
|
||||
var links = engine?.Activity.RecentLinks(LinkWindow) ?? [];
|
||||
|
||||
var pending = staging is null ? [] : await staging.ListPendingAsync(ct);
|
||||
var pendingByAgent = pending
|
||||
.GroupBy(c => c.AgentId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var lastReceipt = new Dictionary<string, RunReceipt>(StringComparer.OrdinalIgnoreCase);
|
||||
if (audit is not null)
|
||||
{
|
||||
var receipts = await audit.ListReceiptsAsync(now - ReceiptWindow, now, 5_000, ct);
|
||||
foreach (var receipt in receipts)
|
||||
{
|
||||
if (!lastReceipt.TryGetValue(receipt.AgentId, out var known) || receipt.OccurredAt > known.OccurredAt)
|
||||
lastReceipt[receipt.AgentId] = receipt;
|
||||
}
|
||||
}
|
||||
|
||||
var members = new List<TeamMember>();
|
||||
foreach (var agent in configured)
|
||||
{
|
||||
var runs = active
|
||||
.Where(r => string.Equals(r.AgentId, agent.AgentId, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(r => r.StartedAtUtc)
|
||||
.ToList();
|
||||
var approvals = pendingByAgent.GetValueOrDefault(agent.AgentId);
|
||||
lastReceipt.TryGetValue(agent.AgentId, out var last);
|
||||
var typical = await TypicalRunAsync(agent.AgentId, ct);
|
||||
|
||||
var state = Classify(runs, approvals, last?.Status, typical, now);
|
||||
members.Add(new TeamMember(
|
||||
agent.AgentId, agent.DisplayName, agent.Model, state, runs, approvals,
|
||||
last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds));
|
||||
}
|
||||
|
||||
return new TeamSnapshot(members, links, now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The state from the facts. Pure, so the rules can be tested without an instance.
|
||||
/// </summary>
|
||||
public static TeamState Classify(
|
||||
IReadOnlyList<ActiveRun> runs, int pendingApprovals, string? lastRunStatus, TimeSpan typicalRun, DateTime nowUtc)
|
||||
{
|
||||
if (runs.Count > 0)
|
||||
{
|
||||
var limit = typicalRun > TimeSpan.Zero
|
||||
? TimeSpan.FromTicks(Math.Max(typicalRun.Ticks * StuckFactor, StuckFloor.Ticks))
|
||||
: StuckWithoutHistory;
|
||||
if (runs.Any(r => nowUtc - r.StartedAtUtc > limit))
|
||||
return TeamState.Stuck;
|
||||
}
|
||||
|
||||
if (pendingApprovals > 0)
|
||||
return TeamState.WaitingForApproval;
|
||||
|
||||
if (runs.Count > 0)
|
||||
return TeamState.Working;
|
||||
|
||||
return lastRunStatus switch
|
||||
{
|
||||
nameof(AgentRunStatus.BudgetExceeded) => TeamState.BudgetExhausted,
|
||||
nameof(AgentRunStatus.Failed)
|
||||
or nameof(AgentRunStatus.LoopLimitExceeded)
|
||||
or nameof(AgentRunStatus.TargetBusy) => TeamState.Problem,
|
||||
_ => TeamState.Idle
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The agent's typical run duration — the median of its recent runs.</summary>
|
||||
private async Task<TimeSpan> TypicalRunAsync(string agentId, CancellationToken ct)
|
||||
{
|
||||
if (usage is null)
|
||||
return TimeSpan.Zero;
|
||||
|
||||
var recent = await usage.GetRecentForAgentAsync(agentId, 20, ct);
|
||||
var durations = recent.Where(r => r.DurationMs > 0).Select(r => r.DurationMs).Order().ToList();
|
||||
return durations.Count == 0 ? TimeSpan.Zero : TimeSpan.FromMilliseconds(durations[durations.Count / 2]);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,29 @@ public sealed class SqliteUsageRepository : IUsageRepository
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RunUsage>> GetRecentForAgentAsync(string agentId, int limit, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT Id, AgentId, Model, PromptTokens, CompletionTokens, CachedTokens,
|
||||
CostUsd, CostIsKnown, Status, StepCount, DurationMs, OccurredAt
|
||||
FROM RunUsage
|
||||
WHERE AgentId = @agent COLLATE NOCASE
|
||||
ORDER BY OccurredAt DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@agent", agentId);
|
||||
cmd.Parameters.AddWithValue("@limit", Math.Clamp(limit, 1, 1000));
|
||||
|
||||
var results = new List<RunUsage>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(Read(reader));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public Task<int> PurgeBeforeAsync(DateOnly date, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
|
||||
@@ -53,6 +53,14 @@ public interface IUsageRepository
|
||||
|
||||
Task<IReadOnlyList<RunUsage>> GetRecentAsync(int limit, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// #716: The most recent runs of one agent — the basis for estimating what a run of
|
||||
/// it will cost. Without an implementation there is no estimate, and a reservation
|
||||
/// counts nothing.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<RunUsage>> GetRecentForAgentAsync(string agentId, int limit, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<RunUsage>>([]);
|
||||
|
||||
/// <summary>Entfernt Einträge älter als das angegebene Datum. Gibt die Anzahl zurück.</summary>
|
||||
Task<int> PurgeBeforeAsync(DateOnly date, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using ClawdDotNet.Core.Tools;
|
||||
|
||||
namespace ClawdDotNet.Core.Activity;
|
||||
|
||||
/// <summary>A run that is in progress right now.</summary>
|
||||
public sealed record ActiveRun(
|
||||
string RunId,
|
||||
string AgentId,
|
||||
string Source,
|
||||
string? TaskId,
|
||||
string? CalledBy,
|
||||
DateTime StartedAtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// One agent reaching another — for now a delegated run (<see cref="Kind"/> =
|
||||
/// <c>delegation</c>). Meetings and escalations will add their own kinds.
|
||||
/// </summary>
|
||||
public sealed record AgentLink(string From, string To, string Kind, DateTime AtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// #711: Who is working right now, and who reached whom — the live side of the team overview.
|
||||
///
|
||||
/// <para><b>Why a component of its own.</b> The engine already tracked running chats, but
|
||||
/// only those that went through <c>ChatAsync</c>; a fresh run (<c>@new</c>) was invisible to
|
||||
/// <c>IsRunning</c>. And nothing recorded that agent A had called agent B — the delegation
|
||||
/// chain knew it for the length of the call and then forgot. Here both are kept and
|
||||
/// announced, independent of which engine path a run takes.</para>
|
||||
///
|
||||
/// <para><b>Memory only, and bounded.</b> This is the view of "now". What happened
|
||||
/// yesterday is in the audit log; the time line (#711, later) replays it from there.</para>
|
||||
/// </summary>
|
||||
public sealed class AgentActivity
|
||||
{
|
||||
/// <summary>How many recent links are kept for a page that connects late.</summary>
|
||||
public const int RecentLinkCapacity = 500;
|
||||
|
||||
private readonly Lock _lock = new();
|
||||
private readonly Dictionary<string, ActiveRun> _active = new(StringComparer.Ordinal);
|
||||
private readonly Queue<AgentLink> _recentLinks = new();
|
||||
private readonly TimeProvider _clock;
|
||||
|
||||
public AgentActivity(TimeProvider? clock = null) => _clock = clock ?? TimeProvider.System;
|
||||
|
||||
public event Action<ActiveRun>? RunStarted;
|
||||
|
||||
/// <summary>A run ended — with the <c>AgentRunStatus</c> name, or <c>Aborted</c>.</summary>
|
||||
public event Action<ActiveRun, string>? RunFinished;
|
||||
|
||||
public event Action<AgentLink>? LinkObserved;
|
||||
|
||||
/// <summary>
|
||||
/// Announces a run. Dispose the handle when it ends; call <see cref="RunHandle.Complete"/>
|
||||
/// first to report how. A delegated run (<paramref name="chain"/> deeper than its root)
|
||||
/// also records the link from the caller.
|
||||
/// </summary>
|
||||
public RunHandle Begin(string runId, string agentId, string source, string? taskId, DelegationChain? chain)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var calledBy = chain is { Depth: > 0 } ? chain.Agents[^2] : null;
|
||||
var run = new ActiveRun(runId, agentId, source, taskId, calledBy, now);
|
||||
|
||||
AgentLink? link = calledBy is null ? null : new AgentLink(calledBy, agentId, "delegation", now);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_active[runId] = run;
|
||||
if (link is not null)
|
||||
{
|
||||
_recentLinks.Enqueue(link);
|
||||
while (_recentLinks.Count > RecentLinkCapacity)
|
||||
_recentLinks.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
Raise(() => RunStarted?.Invoke(run));
|
||||
if (link is not null)
|
||||
Raise(() => LinkObserved?.Invoke(link));
|
||||
|
||||
return new RunHandle(this, run);
|
||||
}
|
||||
|
||||
/// <summary>The runs in progress right now.</summary>
|
||||
public IReadOnlyList<ActiveRun> ActiveRuns()
|
||||
{
|
||||
lock (_lock)
|
||||
return [.. _active.Values];
|
||||
}
|
||||
|
||||
/// <summary>Links observed within <paramref name="window"/>, oldest first.</summary>
|
||||
public IReadOnlyList<AgentLink> RecentLinks(TimeSpan window)
|
||||
{
|
||||
var since = _clock.GetUtcNow().UtcDateTime - window;
|
||||
lock (_lock)
|
||||
return [.. _recentLinks.Where(l => l.AtUtc >= since)];
|
||||
}
|
||||
|
||||
private void End(ActiveRun run, string status)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_active.Remove(run.RunId))
|
||||
return;
|
||||
}
|
||||
|
||||
Raise(() => RunFinished?.Invoke(run, status));
|
||||
}
|
||||
|
||||
/// <summary>A subscriber that throws must not break the run it is watching.</summary>
|
||||
private static void Raise(Action raise)
|
||||
{
|
||||
try { raise(); }
|
||||
catch { /* observers are best effort */ }
|
||||
}
|
||||
|
||||
public sealed class RunHandle : IDisposable
|
||||
{
|
||||
private readonly AgentActivity _owner;
|
||||
private readonly ActiveRun _run;
|
||||
private string _status = "Aborted";
|
||||
|
||||
internal RunHandle(AgentActivity owner, ActiveRun run)
|
||||
{
|
||||
_owner = owner;
|
||||
_run = run;
|
||||
}
|
||||
|
||||
/// <summary>How the run ended. Without it, disposal reports <c>Aborted</c>.</summary>
|
||||
public void Complete(string status) => _status = status;
|
||||
|
||||
public void Dispose() => _owner.End(_run, _status);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,13 @@ public enum BudgetLimit
|
||||
InstanceTokens
|
||||
}
|
||||
|
||||
/// <summary>#716: What a run is expected to use — the agent's average over recent runs.</summary>
|
||||
public readonly record struct RunEstimate(decimal CostUsd, long Tokens)
|
||||
{
|
||||
public static RunEstimate operator +(RunEstimate a, RunEstimate b) => new(a.CostUsd + b.CostUsd, a.Tokens + b.Tokens);
|
||||
public static RunEstimate operator -(RunEstimate a, RunEstimate b) => new(a.CostUsd - b.CostUsd, a.Tokens - b.Tokens);
|
||||
}
|
||||
|
||||
public sealed record BudgetStatus(bool IsAllowed, BudgetLimit Limit, string? Reason)
|
||||
{
|
||||
public static BudgetStatus Allowed { get; } = new(true, BudgetLimit.None, null);
|
||||
@@ -34,6 +41,19 @@ public sealed class BudgetGuard
|
||||
private readonly IUsageRepository _usage;
|
||||
private readonly Func<DateTime> _now;
|
||||
|
||||
/// <summary>How many recent runs of an agent make up its per-run estimate (#716).</summary>
|
||||
public const int EstimateSample = 20;
|
||||
|
||||
/// <summary>
|
||||
/// #716: Check and reserve happen under this lock. Without it, two runs starting at
|
||||
/// the same moment would both read the same "used" figure and both pass.
|
||||
/// </summary>
|
||||
private readonly SemaphoreSlim _reserveGate = new(1, 1);
|
||||
|
||||
private readonly Lock _reservedLock = new();
|
||||
private readonly Dictionary<string, RunEstimate> _reservedByAgent = new(StringComparer.OrdinalIgnoreCase);
|
||||
private RunEstimate _reservedInstance;
|
||||
|
||||
public BudgetGuard(IUsageRepository usage, Func<DateTime>? now = null)
|
||||
{
|
||||
_usage = usage;
|
||||
@@ -43,8 +63,116 @@ public sealed class BudgetGuard
|
||||
/// <summary>
|
||||
/// Prüft, ob der Agent heute noch laufen darf.
|
||||
/// </summary>
|
||||
public async Task<BudgetStatus> CheckAsync(
|
||||
public Task<BudgetStatus> CheckAsync(
|
||||
AgentConfig agent, InstanceBudget instanceBudget, CancellationToken ct)
|
||||
=> CheckCoreAsync(agent, instanceBudget, default, default, ct);
|
||||
|
||||
/// <summary>
|
||||
/// #716: Checks like <see cref="CheckAsync"/> and, if the run may start, reserves its
|
||||
/// estimated usage until the returned handle is disposed — i.e. until the run's usage
|
||||
/// is recorded.
|
||||
///
|
||||
/// <para><b>What changes.</b> Before, each run was checked on its own against what was
|
||||
/// already recorded. Runs in flight were invisible, so several starting together all
|
||||
/// passed and exceeded the budget together (#682 capped how many; this closes it).
|
||||
/// Now the runs in flight count with their estimate.</para>
|
||||
///
|
||||
/// <para><b>What does not change.</b> A single run is still refused only when the
|
||||
/// budget is already used up — its own estimate is not added. Otherwise a small task
|
||||
/// would be refused because the agent's average run is large.</para>
|
||||
/// </summary>
|
||||
public async Task<(BudgetStatus Status, IDisposable? Reservation)> ReserveAsync(
|
||||
AgentConfig agent, InstanceBudget instanceBudget, CancellationToken ct)
|
||||
{
|
||||
if (!agent.Budget.HasAnyLimit && !instanceBudget.HasAnyLimit)
|
||||
return (BudgetStatus.Allowed, null);
|
||||
|
||||
var estimate = await EstimateRunAsync(agent.AgentId, ct);
|
||||
|
||||
await _reserveGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
RunEstimate agentReserved, instanceReserved;
|
||||
lock (_reservedLock)
|
||||
{
|
||||
agentReserved = _reservedByAgent.GetValueOrDefault(agent.AgentId);
|
||||
instanceReserved = _reservedInstance;
|
||||
}
|
||||
|
||||
var status = await CheckCoreAsync(agent, instanceBudget, agentReserved, instanceReserved, ct);
|
||||
if (!status.IsAllowed)
|
||||
return (status, null);
|
||||
|
||||
lock (_reservedLock)
|
||||
{
|
||||
_reservedByAgent[agent.AgentId] = _reservedByAgent.GetValueOrDefault(agent.AgentId) + estimate;
|
||||
_reservedInstance += estimate;
|
||||
}
|
||||
|
||||
return (status, new Reservation(this, agent.AgentId, estimate));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_reserveGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <summary>What is currently reserved by runs in flight — for the agent, or with an
|
||||
/// empty id for the whole instance.</summary>
|
||||
public RunEstimate Reserved(string agentId)
|
||||
{
|
||||
lock (_reservedLock)
|
||||
return agentId.Length == 0 ? _reservedInstance : _reservedByAgent.GetValueOrDefault(agentId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The average of the agent's recent runs. Runs without usage (refusals, aborted
|
||||
/// before the first call) do not count — they would drag the estimate towards zero.
|
||||
/// </summary>
|
||||
public async Task<RunEstimate> EstimateRunAsync(string agentId, CancellationToken ct)
|
||||
{
|
||||
var recent = await _usage.GetRecentForAgentAsync(agentId, EstimateSample, ct);
|
||||
var runs = recent.Where(r => r.TotalTokens > 0).ToList();
|
||||
if (runs.Count == 0)
|
||||
return default;
|
||||
|
||||
return new RunEstimate(
|
||||
runs.Average(r => r.CostUsd),
|
||||
(long)Math.Ceiling(runs.Average(r => (double)r.TotalTokens)));
|
||||
}
|
||||
|
||||
private void Release(string agentId, RunEstimate estimate)
|
||||
{
|
||||
lock (_reservedLock)
|
||||
{
|
||||
var left = _reservedByAgent.GetValueOrDefault(agentId) - estimate;
|
||||
if (left.CostUsd <= 0 && left.Tokens <= 0)
|
||||
_reservedByAgent.Remove(agentId);
|
||||
else
|
||||
_reservedByAgent[agentId] = left;
|
||||
|
||||
_reservedInstance -= estimate;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Reservation(BudgetGuard guard, string agentId, RunEstimate estimate) : IDisposable
|
||||
{
|
||||
private int _disposed;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 0)
|
||||
guard.Release(agentId, estimate);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<BudgetStatus> CheckCoreAsync(
|
||||
AgentConfig agent, InstanceBudget instanceBudget,
|
||||
RunEstimate agentReserved, RunEstimate instanceReserved, CancellationToken ct)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(_now());
|
||||
|
||||
@@ -54,19 +182,21 @@ public sealed class BudgetGuard
|
||||
{
|
||||
var used = await _usage.GetDailyAsync(today, agent.AgentId, ct);
|
||||
|
||||
if (Exceeds(agentBudget.DailyCostUsd, used.CostUsd))
|
||||
if (Exceeds(agentBudget.DailyCostUsd, used.CostUsd + agentReserved.CostUsd))
|
||||
{
|
||||
return new BudgetStatus(false, BudgetLimit.AgentCost,
|
||||
$"Tagesbudget des Agenten erschöpft: {used.CostUsd:F4} von maximal " +
|
||||
$"{agentBudget.DailyCostUsd:F2} USD verbraucht." +
|
||||
ReservedHint(agentReserved.CostUsd) +
|
||||
UnpricedHint(used));
|
||||
}
|
||||
|
||||
if (Exceeds(agentBudget.DailyTokens, used.TotalTokens))
|
||||
if (Exceeds(agentBudget.DailyTokens, used.TotalTokens + agentReserved.Tokens))
|
||||
{
|
||||
return new BudgetStatus(false, BudgetLimit.AgentTokens,
|
||||
$"Token-Tageslimit des Agenten erschöpft: {used.TotalTokens:N0} von maximal " +
|
||||
$"{agentBudget.DailyTokens:N0}.");
|
||||
$"{agentBudget.DailyTokens:N0}." +
|
||||
ReservedTokensHint(agentReserved.Tokens));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,19 +205,21 @@ public sealed class BudgetGuard
|
||||
{
|
||||
var used = await _usage.GetDailyAsync(today, agentId: "", ct);
|
||||
|
||||
if (Exceeds(instanceBudget.DailyCostUsd, used.CostUsd))
|
||||
if (Exceeds(instanceBudget.DailyCostUsd, used.CostUsd + instanceReserved.CostUsd))
|
||||
{
|
||||
return new BudgetStatus(false, BudgetLimit.InstanceCost,
|
||||
$"Tagesbudget der Instanz erschöpft: {used.CostUsd:F4} von maximal " +
|
||||
$"{instanceBudget.DailyCostUsd:F2} USD verbraucht." +
|
||||
ReservedHint(instanceReserved.CostUsd) +
|
||||
UnpricedHint(used));
|
||||
}
|
||||
|
||||
if (Exceeds(instanceBudget.DailyTokens, used.TotalTokens))
|
||||
if (Exceeds(instanceBudget.DailyTokens, used.TotalTokens + instanceReserved.Tokens))
|
||||
{
|
||||
return new BudgetStatus(false, BudgetLimit.InstanceTokens,
|
||||
$"Token-Tageslimit der Instanz erschöpft: {used.TotalTokens:N0} von maximal " +
|
||||
$"{instanceBudget.DailyTokens:N0}.");
|
||||
$"{instanceBudget.DailyTokens:N0}." +
|
||||
ReservedTokensHint(instanceReserved.Tokens));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +241,13 @@ public sealed class BudgetGuard
|
||||
|
||||
private static bool Exceeds(long limit, long used) => limit > 0 && used >= limit;
|
||||
|
||||
/// <summary>#716: Without this, a refusal at "3.20 of 5.00 USD used" would look wrong.</summary>
|
||||
private static string ReservedHint(decimal reserved)
|
||||
=> reserved > 0 ? $" Weitere {reserved:F4} USD sind für laufende Läufe reserviert." : "";
|
||||
|
||||
private static string ReservedTokensHint(long reserved)
|
||||
=> reserved > 0 ? $" Weitere {reserved:N0} Tokens sind für laufende Läufe reserviert." : "";
|
||||
|
||||
/// <summary>
|
||||
/// Ohne Preisangaben ist die verbrauchte Summe unvollständig — sonst wirkte ein
|
||||
/// niedriger Wert wie ein noch offener Spielraum.
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
|
||||
namespace ClawdDotNet.Core.Budget;
|
||||
|
||||
/// <summary>
|
||||
/// #716: Decides whether a late run — carried-over work or a catch-up — may start now,
|
||||
/// without taking budget the rest of the day still needs.
|
||||
///
|
||||
/// <para><b>The rule: the new day has right of way.</b> Headroom is the limit minus what
|
||||
/// was used, minus what runs in flight have reserved, minus what the day's remaining
|
||||
/// scheduled runs will need, minus a safety margin. A late run starts only if its
|
||||
/// estimate fits into that. Otherwise one overdrawn day would push its shortfall into the
|
||||
/// next, and the next, instead of resolving it.</para>
|
||||
///
|
||||
/// <para>This replaces the flat "at most 25 % of the new day" from the concept: a fixed
|
||||
/// share is either too much on a busy day or needlessly little on a quiet one. The same
|
||||
/// calculation is what fill work (#717) will run on.</para>
|
||||
///
|
||||
/// <para><b>Estimates, honestly.</b> A run is estimated from the agent's recent runs
|
||||
/// (<see cref="BudgetGuard.EstimateRunAsync"/>). Without history the estimate is zero —
|
||||
/// 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
|
||||
{
|
||||
/// <summary>Share of each limit kept free as a reserve for surprises.</summary>
|
||||
public const decimal SafetyShare = 0.10m;
|
||||
|
||||
private readonly BudgetGuard _guard;
|
||||
private readonly Func<IReadOnlyList<AgentConfig>> _agents;
|
||||
private readonly Func<InstanceBudget> _instanceBudget;
|
||||
private readonly TimeProvider _clock;
|
||||
|
||||
public BudgetHeadroom(
|
||||
BudgetGuard guard,
|
||||
Func<IReadOnlyList<AgentConfig>> agents,
|
||||
Func<InstanceBudget> instanceBudget,
|
||||
TimeProvider? clock = null)
|
||||
{
|
||||
_guard = guard;
|
||||
_agents = agents;
|
||||
_instanceBudget = instanceBudget;
|
||||
_clock = clock ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<bool> HasRoomAsync(
|
||||
TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
|
||||
{
|
||||
var agents = _agents();
|
||||
var agent = FindAgent(agents, lateTask);
|
||||
var instanceBudget = _instanceBudget();
|
||||
|
||||
// Without a known agent there is nothing to calculate against; the budget check at
|
||||
// the start of the run still applies.
|
||||
if (agent is null || (!agent.Budget.HasAnyLimit && !instanceBudget.HasAnyLimit))
|
||||
return true;
|
||||
|
||||
var endOfDay = StartOfNextLocalDay(nowUtc);
|
||||
var estimates = new Dictionary<string, RunEstimate>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
async Task<RunEstimate> EstimateFor(string agentId)
|
||||
{
|
||||
if (!estimates.TryGetValue(agentId, out var estimate))
|
||||
estimates[agentId] = estimate = await _guard.EstimateRunAsync(agentId, ct);
|
||||
return estimate;
|
||||
}
|
||||
|
||||
var ownEstimate = 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();
|
||||
|
||||
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));
|
||||
|
||||
var used = await _guard.GetUsedTodayAsync(agent.AgentId, ct);
|
||||
var reserved = _guard.Reserved(agent.AgentId);
|
||||
|
||||
if (!Fits(agent.Budget.DailyCostUsd, used.CostUsd, reserved.CostUsd, demand.CostUsd, ownEstimate.CostUsd)
|
||||
|| !Fits(agent.Budget.DailyTokens, used.TotalTokens, reserved.Tokens, demand.Tokens, ownEstimate.Tokens))
|
||||
return false;
|
||||
}
|
||||
|
||||
if (instanceBudget.HasAnyLimit)
|
||||
{
|
||||
var demand = default(RunEstimate);
|
||||
foreach (var task in regular)
|
||||
{
|
||||
var agentId = TaskAssignee.AgentId(task.Assignee);
|
||||
if (agentId.Length == 0)
|
||||
continue;
|
||||
demand += Times(await EstimateFor(agentId), TaskSchedule.OccurrencesBetween(task, nowUtc, endOfDay));
|
||||
}
|
||||
|
||||
var used = await _guard.GetUsedTodayAsync("", ct);
|
||||
var reserved = _guard.Reserved("");
|
||||
|
||||
if (!Fits(instanceBudget.DailyCostUsd, used.CostUsd, reserved.CostUsd, demand.CostUsd, ownEstimate.CostUsd)
|
||||
|| !Fits(instanceBudget.DailyTokens, used.TotalTokens, reserved.Tokens, demand.Tokens, ownEstimate.Tokens))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BudgetShortfall>> ShortfallsAsync(
|
||||
IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
|
||||
{
|
||||
var agents = _agents();
|
||||
var instanceBudget = _instanceBudget();
|
||||
var endOfDay = StartOfNextLocalDay(nowUtc);
|
||||
var result = new List<BudgetShortfall>();
|
||||
|
||||
// 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)))
|
||||
{
|
||||
var agent = FindAgent(agents, task);
|
||||
if (agent is null)
|
||||
continue;
|
||||
foreach (var at in TaskSchedule.OccurrenceTimesBetween(task, nowUtc, endOfDay))
|
||||
planned.Add((at, agent.AgentId));
|
||||
}
|
||||
|
||||
planned.Sort((a, b) => a.At.CompareTo(b.At));
|
||||
|
||||
var estimates = new Dictionary<string, RunEstimate>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var agentId in planned.Select(p => p.AgentId).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
estimates[agentId] = await _guard.EstimateRunAsync(agentId, ct);
|
||||
|
||||
foreach (var agent in agents.Where(a => a.Budget.HasAnyLimit))
|
||||
{
|
||||
var runs = planned.Where(p => string.Equals(p.AgentId, agent.AgentId, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
var used = await _guard.GetUsedTodayAsync(agent.AgentId, ct);
|
||||
var reserved = _guard.Reserved(agent.AgentId);
|
||||
if (FirstRefused(runs, estimates, agent.Budget.DailyCostUsd, agent.Budget.DailyTokens, used, reserved) is { } index)
|
||||
result.Add(new BudgetShortfall(agent.AgentId, runs[index].At, runs.Count - index, runs.Count));
|
||||
}
|
||||
|
||||
if (instanceBudget.HasAnyLimit)
|
||||
{
|
||||
var used = await _guard.GetUsedTodayAsync("", ct);
|
||||
var reserved = _guard.Reserved("");
|
||||
if (FirstRefused(planned, estimates, instanceBudget.DailyCostUsd, instanceBudget.DailyTokens, used, reserved) is { } index)
|
||||
result.Add(new BudgetShortfall("", planned[index].At, planned.Count - index, planned.Count));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The index of the first run that would find the budget used up, or <c>null</c>.
|
||||
/// Mirrors the check at the start of a run: a run is refused once usage has reached
|
||||
/// the limit, not merely because its own estimate would overshoot.
|
||||
/// </summary>
|
||||
private static int? FirstRefused(
|
||||
List<(DateTime At, string AgentId)> runs, Dictionary<string, RunEstimate> estimates,
|
||||
decimal costLimit, long tokenLimit, Accounting.DailyUsage used, RunEstimate reserved)
|
||||
{
|
||||
var cost = used.CostUsd + reserved.CostUsd;
|
||||
long tokens = used.TotalTokens + reserved.Tokens;
|
||||
|
||||
for (var i = 0; i < runs.Count; i++)
|
||||
{
|
||||
if ((costLimit > 0 && cost >= costLimit) || (tokenLimit > 0 && tokens >= tokenLimit))
|
||||
return i;
|
||||
|
||||
var estimate = estimates.GetValueOrDefault(runs[i].AgentId);
|
||||
cost += estimate.CostUsd;
|
||||
tokens += estimate.Tokens;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Carried-over work and pending catch-ups are the late runs.</summary>
|
||||
public static bool IsLate(TaskItem task) => task.IsCarriedOver || task.CatchUpPending;
|
||||
|
||||
/// <summary>0 or less means: no limit of this kind.</summary>
|
||||
private static bool Fits(decimal limit, decimal used, decimal reserved, decimal demand, decimal estimate)
|
||||
=> limit <= 0 || limit - used - reserved - demand - limit * SafetyShare >= estimate;
|
||||
|
||||
private static bool Fits(long limit, long used, long reserved, long demand, long estimate)
|
||||
=> limit <= 0 || limit - used - reserved - demand - (long)(limit * SafetyShare) >= estimate;
|
||||
|
||||
private static RunEstimate Times(RunEstimate estimate, int runs)
|
||||
=> new(estimate.CostUsd * runs, estimate.Tokens * runs);
|
||||
|
||||
private static bool IsOf(TaskItem task, string agentId)
|
||||
=> string.Equals(TaskAssignee.AgentId(task.Assignee), agentId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static AgentConfig? FindAgent(IReadOnlyList<AgentConfig> agents, TaskItem task)
|
||||
{
|
||||
var agentId = TaskAssignee.AgentId(task.Assignee);
|
||||
return agentId.Length == 0
|
||||
? (agents.Count == 1 ? agents[0] : null)
|
||||
: agents.FirstOrDefault(a => string.Equals(a.AgentId, agentId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private DateTime StartOfNextLocalDay(DateTime nowUtc)
|
||||
{
|
||||
var zone = _clock.LocalTimeZone;
|
||||
var local = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc), zone);
|
||||
var midnight = DateTime.SpecifyKind(local.Date.AddDays(1), DateTimeKind.Unspecified);
|
||||
while (zone.IsInvalidTime(midnight))
|
||||
midnight = midnight.AddMinutes(1);
|
||||
return TimeZoneInfo.ConvertTimeToUtc(midnight, zone);
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,21 @@ public sealed class AgentBudget
|
||||
[JsonPropertyName("dailyTokens")]
|
||||
public long DailyTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// #716: What happens to a run of a recurring task when the budget is exhausted —
|
||||
/// <c>expire</c> (default) or <c>catchUpOnce</c>. One-time tasks are always carried
|
||||
/// over to the next day. Kept as text so an unknown value falls back instead of
|
||||
/// failing the whole configuration.
|
||||
/// </summary>
|
||||
[JsonPropertyName("whenExhausted")]
|
||||
public string WhenExhausted { get; set; } = "expire";
|
||||
|
||||
[JsonIgnore]
|
||||
public Tasks.BudgetMissPolicy MissPolicy
|
||||
=> string.Equals(WhenExhausted?.Trim(), "catchUpOnce", StringComparison.OrdinalIgnoreCase)
|
||||
? Tasks.BudgetMissPolicy.CatchUpOnce
|
||||
: Tasks.BudgetMissPolicy.Expire;
|
||||
|
||||
[JsonIgnore]
|
||||
public bool HasAnyLimit => DailyCostUsd > 0 || DailyTokens > 0;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,15 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
/// <summary>Tagesgrenzen der Instanz. Wird vom Host gesetzt.</summary>
|
||||
public InstanceBudget InstanceBudget { get; set; } = InstanceBudget.Unlimited;
|
||||
|
||||
/// <summary>
|
||||
/// #716: The guard that checks and reserves every run — the scanner's headroom
|
||||
/// calculation has to see the same reservations. <c>null</c> without usage recording.
|
||||
/// </summary>
|
||||
public BudgetGuard? BudgetGuard => _budgetGuard;
|
||||
|
||||
/// <summary>#711: Who is working right now and who reached whom — for the team overview.</summary>
|
||||
public Activity.AgentActivity Activity { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
|
||||
/// A→B ist die Tiefe 1, A→B→C die Tiefe 2.
|
||||
@@ -163,15 +172,20 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
return zu;
|
||||
|
||||
// Vor der ersten Anfrage prüfen — ein erschöpftes Budget soll gar nichts kosten.
|
||||
if (await CheckBudgetAsync(agentConfig, source ?? AuditSource.Direct, taskId, externalCt) is { } denied)
|
||||
// #716: Die Reservierung gilt, bis der Verbrauch erfasst ist (Ende der Methode).
|
||||
var (denied, reservation) = await CheckBudgetAsync(agentConfig, source ?? AuditSource.Direct, taskId, externalCt);
|
||||
if (denied is not null)
|
||||
return denied;
|
||||
using var budgetReservation = reservation;
|
||||
|
||||
using var lauf = TrackRun();
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId);
|
||||
using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
|
||||
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);
|
||||
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||
@@ -444,8 +458,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
if (RefuseWhileShuttingDown(agentConfig) is { } zu)
|
||||
return zu;
|
||||
|
||||
if (await CheckBudgetAsync(agentConfig, source, taskId, externalCt) is { } denied)
|
||||
var (denied, reservation) = await CheckBudgetAsync(agentConfig, source, taskId, externalCt);
|
||||
if (denied is not null)
|
||||
return denied;
|
||||
using var budgetReservation = reservation;
|
||||
|
||||
using var lauf = TrackRun();
|
||||
|
||||
@@ -487,8 +503,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
try
|
||||
{
|
||||
// #711: "working" starts past the gate — waiting for it is not working.
|
||||
using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
|
||||
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);
|
||||
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||
@@ -1638,17 +1657,17 @@ 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.
|
||||
/// darf — sonst null und, bei gesetzter Grenze, die Reservierung des Laufs (#716).
|
||||
/// </summary>
|
||||
private async Task<AgentRunResult?> CheckBudgetAsync(
|
||||
private async Task<(AgentRunResult? Denied, IDisposable? Reservation)> CheckBudgetAsync(
|
||||
AgentConfig agentConfig, string? source, string? taskId, CancellationToken ct)
|
||||
{
|
||||
if (_budgetGuard is null)
|
||||
return null;
|
||||
return (null, null);
|
||||
|
||||
var status = await _budgetGuard.CheckAsync(agentConfig, InstanceBudget, ct);
|
||||
var (status, reservation) = await _budgetGuard.ReserveAsync(agentConfig, InstanceBudget, ct);
|
||||
if (status.IsAllowed)
|
||||
return null;
|
||||
return (null, reservation);
|
||||
|
||||
// #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.
|
||||
@@ -1676,7 +1695,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
await RecordReceiptAsync(Guid.NewGuid().ToString("N"), agentConfig, result, source, taskId);
|
||||
|
||||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||||
return result;
|
||||
return (result, null);
|
||||
}
|
||||
|
||||
/// <summary>Welche Budgetgrenze je Agent zuletzt an welchem Tag als Warnung gemeldet wurde.</summary>
|
||||
|
||||
@@ -339,6 +339,12 @@ public sealed class SqliteStorage
|
||||
// #554: Paart den "angefangen"-Eintrag mit seinem Abschluss.
|
||||
SpalteErgaenzen(connection, "AuditLog", "CallId", "TEXT NOT NULL DEFAULT ''");
|
||||
|
||||
// #716: What happened when the budget refused a run.
|
||||
SpalteErgaenzen(connection, "Tasks", "BudgetMissCount", "INTEGER NOT NULL DEFAULT 0");
|
||||
SpalteErgaenzen(connection, "Tasks", "LastBudgetMissAt", "TEXT NULL");
|
||||
SpalteErgaenzen(connection, "Tasks", "DeferredUntil", "TEXT NULL");
|
||||
SpalteErgaenzen(connection, "Tasks", "CatchUpPending", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
|
||||
using var index = connection.CreateCommand();
|
||||
index.CommandText = """
|
||||
|
||||
@@ -98,7 +98,8 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
return result.Status switch
|
||||
{
|
||||
AgentRunStatus.Completed => TaskLauf.Erledigt,
|
||||
AgentRunStatus.BudgetExceeded or AgentRunStatus.ShuttingDown => TaskLauf.NichtGelaufen,
|
||||
AgentRunStatus.BudgetExceeded => TaskLauf.KeinBudget,
|
||||
AgentRunStatus.ShuttingDown => TaskLauf.NichtGelaufen,
|
||||
_ => TaskLauf.Gescheitert
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,6 +29,13 @@ public interface ITaskRepository
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TaskItem>> ListRunnableAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Every task, without a cap — for the few callers that must see the whole board: the
|
||||
/// cost forecast, duplicate detection, the import's orphan sweep. Before #716 they used
|
||||
/// <see cref="ListAsync"/> with its limit of 500 and silently saw only part of it.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TaskItem>> ListAllAsync(bool includeArchived, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Beansprucht einen fälligen Termin atomar. Genau ein gleichzeitiger Aufruf gewinnt;
|
||||
/// jeder weitere erhält <c>false</c>. Setzt den Marker (<paramref name="occurrenceKey"/>)
|
||||
@@ -65,6 +72,28 @@ public interface ITaskRepository
|
||||
/// <summary>Ändert den Status direkt (Tool-Aktionen, Auto-Dispatch, Eskalation).</summary>
|
||||
Task<bool> SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// #716: The budget refused a run of a one-time task. Releases the claim, restores the
|
||||
/// occurrence (the task is still due) and defers it to <paramref name="deferredUntil"/>.
|
||||
/// </summary>
|
||||
Task<bool> CarryOverAsync(
|
||||
string id, string claimToken, string? previousOccurrence, DateTime deferredUntil,
|
||||
DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// #716: The budget refused a run of a recurring task. Releases the claim back to
|
||||
/// <c>todo</c>; the occurrence stays consumed. With <paramref name="catchUpUntil"/> a
|
||||
/// single catch-up run is scheduled for then — an already pending one is kept.
|
||||
/// </summary>
|
||||
Task<bool> RecordBudgetMissAsync(
|
||||
string id, string claimToken, DateTime? catchUpUntil, DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// #716: A run took place — clears the miss counter, a pending catch-up and the
|
||||
/// deferral. <see cref="TaskItem.LastBudgetMissAt"/> stays as history.
|
||||
/// </summary>
|
||||
Task<bool> ClearBudgetMissAsync(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
|
||||
|
||||
@@ -193,6 +193,68 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}, ct);
|
||||
|
||||
public Task<bool> CarryOverAsync(
|
||||
string id, string claimToken, string? previousOccurrence, DateTime deferredUntil,
|
||||
DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE Tasks
|
||||
SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL,
|
||||
LastOccurrence = @occ, UpdatedAt = @now,
|
||||
BudgetMissCount = BudgetMissCount + 1, LastBudgetMissAt = @now,
|
||||
DeferredUntil = @deferred
|
||||
WHERE Id = @id AND ClaimToken = @token
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@occ", (object?)previousOccurrence ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@deferred", Format(deferredUntil));
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
cmd.Parameters.AddWithValue("@token", claimToken);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}, ct);
|
||||
|
||||
public Task<bool> RecordBudgetMissAsync(
|
||||
string id, string claimToken, DateTime? catchUpUntil, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
// A pending catch-up keeps its date: several misses still mean one catch-up run.
|
||||
cmd.CommandText = """
|
||||
UPDATE Tasks
|
||||
SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now,
|
||||
BudgetMissCount = BudgetMissCount + 1, LastBudgetMissAt = @now,
|
||||
CatchUpPending = CASE WHEN @catchUp = 1 THEN 1 ELSE CatchUpPending END,
|
||||
DeferredUntil = CASE WHEN @catchUp = 1 AND CatchUpPending = 0 THEN @deferred
|
||||
ELSE DeferredUntil END
|
||||
WHERE Id = @id AND ClaimToken = @token
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@catchUp", catchUpUntil is null ? 0 : 1);
|
||||
cmd.Parameters.AddWithValue("@deferred", catchUpUntil is { } until ? Format(until) : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
cmd.Parameters.AddWithValue("@token", claimToken);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}, ct);
|
||||
|
||||
public Task<bool> ClearBudgetMissAsync(string id, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE Tasks
|
||||
SET BudgetMissCount = 0, CatchUpPending = 0, DeferredUntil = NULL, 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 =>
|
||||
{
|
||||
@@ -208,6 +270,22 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
return await cmd.ExecuteNonQueryAsync(ct);
|
||||
}, ct);
|
||||
|
||||
public async Task<IReadOnlyList<TaskItem>> ListAllAsync(bool includeArchived, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + Columns + " FROM Tasks"
|
||||
+ (includeArchived ? "" : " WHERE Status <> 'archived'")
|
||||
+ " ORDER BY Priority DESC, UpdatedAt DESC";
|
||||
|
||||
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<IReadOnlyList<TaskItem>> ListRunnableAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
@@ -252,7 +330,8 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
private const string Columns =
|
||||
"Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " +
|
||||
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
|
||||
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt";
|
||||
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, "
|
||||
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending";
|
||||
|
||||
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
|
||||
{
|
||||
@@ -318,7 +397,11 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
ClaimToken = r.IsDBNull(18) ? null : r.GetString(18),
|
||||
ClaimedAt = r.IsDBNull(19) ? null : Parse(r.GetString(19)),
|
||||
CreatedAt = Parse(r.GetString(20)),
|
||||
UpdatedAt = Parse(r.GetString(21))
|
||||
UpdatedAt = Parse(r.GetString(21)),
|
||||
BudgetMissCount = r.GetInt32(22),
|
||||
LastBudgetMissAt = r.IsDBNull(23) ? null : Parse(r.GetString(23)),
|
||||
DeferredUntil = r.IsDBNull(24) ? null : Parse(r.GetString(24)),
|
||||
CatchUpPending = r.GetInt32(25) != 0
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +205,32 @@ public sealed record TaskItem
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
|
||||
// ─── Budget misses (#716) ───
|
||||
|
||||
/// <summary>
|
||||
/// How many times in a row a run of this task was refused because the budget was
|
||||
/// exhausted. Reset by the next run that actually takes place.
|
||||
/// </summary>
|
||||
public int BudgetMissCount { get; init; }
|
||||
|
||||
/// <summary>When the last budget refusal happened (UTC). Kept after a reset, as history.</summary>
|
||||
public DateTime? LastBudgetMissAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Not before this point (UTC). Set after a budget refusal to the start of the next
|
||||
/// local day: retrying within the same day only produces further refusals.
|
||||
/// </summary>
|
||||
public DateTime? DeferredUntil { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// A recurring task under <see cref="BudgetMissPolicy.CatchUpOnce"/> missed at least
|
||||
/// one occurrence; all of them are caught up by a single run.
|
||||
/// </summary>
|
||||
public bool CatchUpPending { get; init; }
|
||||
|
||||
/// <summary>A one-time task that was refused for lack of budget and carried over.</summary>
|
||||
public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Wiederkehrend? Solche Tasks kehren nach dem Feuern auf <c>todo</c> zurück statt auf
|
||||
/// <c>done</c> — sonst würde ein Cron-Task nur ein einziges Mal laufen. Ein
|
||||
@@ -215,6 +241,19 @@ public sealed record TaskItem
|
||||
|| When is { Kind: TaskWhenKind.Cron or TaskWhenKind.Every };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #716: What happens to a run of a recurring task that the budget refused. One-time
|
||||
/// tasks are always carried over — they are real work that must not disappear.
|
||||
/// </summary>
|
||||
public enum BudgetMissPolicy
|
||||
{
|
||||
/// <summary>The occurrence lapses and is recorded; the next regular one comes anyway.</summary>
|
||||
Expire,
|
||||
|
||||
/// <summary>All missed occurrences are caught up by one run on the next day.</summary>
|
||||
CatchUpOnce
|
||||
}
|
||||
|
||||
/// <summary>Suchkriterien für <see cref="ITaskRepository.ListAsync"/>.</summary>
|
||||
public sealed record TaskQuery
|
||||
{
|
||||
|
||||
@@ -12,10 +12,17 @@ public enum TaskLauf
|
||||
Gescheitert,
|
||||
|
||||
/// <summary>
|
||||
/// Der Lauf fand gar nicht statt — Budget erschöpft, Instanz im Herunterfahren (#643).
|
||||
/// Der Lauf fand gar nicht statt — Instanz im Herunterfahren (#643).
|
||||
/// Eine einmalige Aufgabe bleibt dann fällig, statt als gelaufen liegenzubleiben.
|
||||
/// </summary>
|
||||
NichtGelaufen
|
||||
NichtGelaufen,
|
||||
|
||||
/// <summary>
|
||||
/// #716: The run did not take place because the budget is exhausted. Unlike a
|
||||
/// shutdown, retrying the same day is pointless — the scanner defers to the next day
|
||||
/// and applies the agent's <see cref="BudgetMissPolicy"/>.
|
||||
/// </summary>
|
||||
KeinBudget
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -37,6 +44,30 @@ public interface IMarketCalendar
|
||||
bool IsOpen(DateTime nowUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #716: Decides whether a late run — carried-over work or a catch-up — may start now.
|
||||
/// Without one, late runs only wait for the regular ones of the same tick.
|
||||
/// </summary>
|
||||
public interface ILateRunGate
|
||||
{
|
||||
Task<bool> HasRoomAsync(TaskItem lateTask, 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>
|
||||
public interface IBudgetOutlook
|
||||
{
|
||||
Task<IReadOnlyList<BudgetShortfall>> ShortfallsAsync(IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #716: An agent whose budget runs out before today's schedule does — from when on, and
|
||||
/// how many planned runs will be refused. <see cref="AgentId"/> is empty for the instance
|
||||
/// limit.
|
||||
/// </summary>
|
||||
public sealed record BudgetShortfall(string AgentId, DateTime ExhaustsAtUtc, int RunsRefused, int RunsPlanned);
|
||||
|
||||
/// <summary>Bis C1 kommt: der Markt gilt als immer offen.</summary>
|
||||
public sealed class AlwaysOpenMarketCalendar : IMarketCalendar
|
||||
{
|
||||
@@ -98,6 +129,26 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
/// <summary>How many due tasks had to wait at the last tick — logged only on change.</summary>
|
||||
private int _lastWaiting;
|
||||
|
||||
/// <summary>#716: What the budget refused, per agent, for the day summary.</summary>
|
||||
private readonly Func<TaskItem, BudgetMissPolicy> _missPolicy;
|
||||
private readonly Dictionary<string, (int Misses, int CarriedOver, int CatchUps)> _missesToday = new(StringComparer.OrdinalIgnoreCase);
|
||||
private DateOnly? _missDay;
|
||||
|
||||
private readonly ILateRunGate? _lateRunGate;
|
||||
|
||||
private readonly IBudgetOutlook? _outlook;
|
||||
|
||||
/// <summary>How often the day ahead is looked at — a forecast, not a watchdog.</summary>
|
||||
public static readonly TimeSpan OutlookInterval = TimeSpan.FromMinutes(30);
|
||||
|
||||
private DateTime _lastOutlookAt = DateTime.MinValue;
|
||||
|
||||
/// <summary>Agents already warned about today, with the day — once per agent and day.</summary>
|
||||
private readonly Dictionary<string, DateOnly> _shortfallWarned = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>How many late runs waited for budget headroom at the last tick.</summary>
|
||||
private int _lastWaitingForHeadroom;
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _loop;
|
||||
|
||||
@@ -109,7 +160,10 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
TimeProvider? clock = null,
|
||||
TimeSpan? tick = null,
|
||||
TimeSpan? lease = null,
|
||||
int maxParallelRuns = DefaultMaxParallelRuns)
|
||||
int maxParallelRuns = DefaultMaxParallelRuns,
|
||||
Func<TaskItem, BudgetMissPolicy>? budgetMissPolicy = null,
|
||||
ILateRunGate? lateRunGate = null,
|
||||
IBudgetOutlook? budgetOutlook = null)
|
||||
{
|
||||
_repo = repo;
|
||||
_dispatcher = dispatcher;
|
||||
@@ -119,6 +173,9 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
_tick = tick ?? TimeSpan.FromSeconds(60);
|
||||
_lease = lease ?? TimeSpan.FromMinutes(15);
|
||||
_maxParallelRuns = Math.Max(1, maxParallelRuns);
|
||||
_missPolicy = budgetMissPolicy ?? (_ => BudgetMissPolicy.Expire);
|
||||
_lateRunGate = lateRunGate;
|
||||
_outlook = budgetOutlook;
|
||||
}
|
||||
|
||||
/// <summary>How many scheduled runs may be in flight at once (#682).</summary>
|
||||
@@ -226,7 +283,10 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
// #682: ungekappt. Vorher ListAsync mit Limit 500 — darüber fielen Termine lautlos aus.
|
||||
var candidates = await _repo.ListRunnableAsync(ct);
|
||||
|
||||
var due = new List<(TaskItem Task, string Occurrence)>();
|
||||
SummarizeMissesIfNewDay(now);
|
||||
await WarnAboutShortfallsAsync(candidates, now, ct);
|
||||
|
||||
var due = new List<(TaskItem Task, string Occurrence, bool Late)>();
|
||||
foreach (var task in candidates)
|
||||
{
|
||||
if (task.Status != TaskItemStatus.Todo)
|
||||
@@ -269,26 +329,42 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
if (_zurueckgestellt.TryGetValue(task.Id, out var ab) && now < ab)
|
||||
continue;
|
||||
|
||||
var occ = TaskSchedule.DueOccurrence(task, now);
|
||||
if (occ is null)
|
||||
// #716: Refused for lack of budget — not before the next day. For a recurring
|
||||
// task this only holds back the catch-up; its regular occurrences keep coming.
|
||||
var deferred = task.DeferredUntil is { } until && now < until;
|
||||
if (deferred && !task.IsRecurring)
|
||||
continue;
|
||||
|
||||
if (task.OnlyWhenMarketOpen && !_market.IsOpen(now))
|
||||
continue;
|
||||
|
||||
due.Add((task, occ));
|
||||
var occ = TaskSchedule.DueOccurrence(task, now);
|
||||
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));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (task.CatchUpPending && !deferred)
|
||||
due.Add((task, Iso(now), Late: true));
|
||||
}
|
||||
|
||||
// #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.
|
||||
due.Sort((a, b) =>
|
||||
{
|
||||
if (a.Late != b.Late)
|
||||
return a.Late ? 1 : -1;
|
||||
var byPriority = b.Task.Priority.CompareTo(a.Task.Priority);
|
||||
return byPriority != 0 ? byPriority : string.CompareOrdinal(a.Occurrence, b.Occurrence);
|
||||
});
|
||||
|
||||
var launched = new List<Task>();
|
||||
var waiting = 0;
|
||||
foreach (var (task, occ) in due)
|
||||
var waitingForHeadroom = 0;
|
||||
foreach (var (task, occ, late) in due)
|
||||
{
|
||||
// OpenCount also counts runs started by hand (RunTaskNowAsync) — they take a
|
||||
// slot, even though they did not wait for one.
|
||||
@@ -298,6 +374,14 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
// #716: Late work only uses what the rest of the day does not need.
|
||||
if (late && _lateRunGate is not null
|
||||
&& !await _lateRunGate.HasRoomAsync(task, candidates, now, ct))
|
||||
{
|
||||
waitingForHeadroom++;
|
||||
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)));
|
||||
@@ -316,6 +400,16 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
_lastWaiting = waiting;
|
||||
}
|
||||
|
||||
if (waitingForHeadroom != _lastWaitingForHeadroom)
|
||||
{
|
||||
if (waitingForHeadroom > 0)
|
||||
_logger.LogInformation(
|
||||
"{Waiting} carried-over or catch-up run(s) waiting for budget headroom — "
|
||||
+ "the rest of today's schedule comes first",
|
||||
waitingForHeadroom);
|
||||
_lastWaitingForHeadroom = waitingForHeadroom;
|
||||
}
|
||||
|
||||
return launched;
|
||||
}
|
||||
|
||||
@@ -413,6 +507,12 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var ok = lauf == TaskLauf.Erledigt;
|
||||
|
||||
if (lauf == TaskLauf.KeinBudget)
|
||||
{
|
||||
await HandleBudgetMissAsync(task, token, now, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// #643: Fand der Lauf einer einmaligen Aufgabe gar nicht statt, gilt ihr Termin als
|
||||
// nicht verbraucht. Vorher blieb sie mit gesetztem Marker für immer auf todo liegen.
|
||||
if (lauf == TaskLauf.NichtGelaufen && !task.IsRecurring)
|
||||
@@ -443,10 +543,148 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
|
||||
await _repo.CompleteClaimAsync(task.Id, token, final, now, ct);
|
||||
|
||||
// #716: A run took place — earlier budget misses are settled.
|
||||
if (lauf != TaskLauf.NichtGelaufen
|
||||
&& (task.BudgetMissCount > 0 || task.CatchUpPending || task.DeferredUntil is not null))
|
||||
await _repo.ClearBudgetMissAsync(task.Id, now, ct);
|
||||
|
||||
if (ok && final == TaskItemStatus.Done)
|
||||
await UnblockDependentsAsync(task.Id, now, ct);
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// occurrence lapses, and under <see cref="BudgetMissPolicy.CatchUpOnce"/> one
|
||||
/// catch-up run is scheduled for the next day.
|
||||
/// </summary>
|
||||
private async Task HandleBudgetMissAsync(TaskItem task, string token, DateTime now, CancellationToken ct)
|
||||
{
|
||||
var nextDay = StartOfNextLocalDay(now);
|
||||
var agent = TaskAssignee.AgentId(task.Assignee);
|
||||
var carried = false;
|
||||
var catchUp = false;
|
||||
|
||||
if (!task.IsRecurring)
|
||||
{
|
||||
carried = await _repo.CarryOverAsync(task.Id, token, task.LastOccurrence, nextDay, now, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
catchUp = _missPolicy(task) == BudgetMissPolicy.CatchUpOnce;
|
||||
await _repo.RecordBudgetMissAsync(task.Id, token, catchUp ? nextDay : null, now, ct);
|
||||
}
|
||||
|
||||
lock (_missesToday)
|
||||
{
|
||||
var entry = _missesToday.GetValueOrDefault(agent);
|
||||
_missesToday[agent] = (entry.Misses + 1,
|
||||
entry.CarriedOver + (carried ? 1 : 0),
|
||||
entry.CatchUps + (catchUp && !task.CatchUpPending ? 1 : 0));
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Task {TaskId} refused for lack of budget — {Outcome}",
|
||||
task.Id,
|
||||
carried ? $"carried over, due again from {nextDay:yyyy-MM-dd HH:mm} UTC"
|
||||
: catchUp ? "occurrence lapsed, one catch-up run scheduled for the next day"
|
||||
: "occurrence lapsed (policy: expire)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #716: Prevent rather than catch up. Every half hour the day ahead is checked; an
|
||||
/// agent whose budget will not last until midnight is named once a day — with the time
|
||||
/// it runs out and how many planned runs that costs — while there is still time to
|
||||
/// raise the budget or decide what can go.
|
||||
/// </summary>
|
||||
private async Task WarnAboutShortfallsAsync(IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
|
||||
{
|
||||
if (_outlook is null || nowUtc - _lastOutlookAt < OutlookInterval)
|
||||
return;
|
||||
_lastOutlookAt = nowUtc;
|
||||
|
||||
IReadOnlyList<BudgetShortfall> shortfalls;
|
||||
try
|
||||
{
|
||||
shortfalls = await _outlook.ShortfallsAsync(runnable, nowUtc, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Budget outlook failed — no forecast this time");
|
||||
return;
|
||||
}
|
||||
|
||||
var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(nowUtc, _clock.LocalTimeZone));
|
||||
foreach (var shortfall in shortfalls)
|
||||
{
|
||||
if (_shortfallWarned.TryGetValue(shortfall.AgentId, out var warnedOn) && warnedOn == today)
|
||||
continue;
|
||||
_shortfallWarned[shortfall.AgentId] = today;
|
||||
|
||||
var local = TimeZoneInfo.ConvertTimeFromUtc(shortfall.ExhaustsAtUtc, _clock.LocalTimeZone);
|
||||
_logger.LogWarning(
|
||||
"Budget forecast: the daily budget of {Who} will run out around {Time:HH:mm} — "
|
||||
+ "{Refused} of {Planned} runs planned for the rest of today would be refused. "
|
||||
+ "Raise the budget or decide what can wait.",
|
||||
shortfall.AgentId.Length == 0 ? "the instance" : $"agent '{shortfall.AgentId}'",
|
||||
local, shortfall.RunsRefused, shortfall.RunsPlanned);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #716: On the first tick of a new local day, one line per agent about what the
|
||||
/// budget refused the day before. The details are on the tasks themselves.
|
||||
/// </summary>
|
||||
private void SummarizeMissesIfNewDay(DateTime nowUtc)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(nowUtc, _clock.LocalTimeZone));
|
||||
|
||||
List<KeyValuePair<string, (int Misses, int CarriedOver, int CatchUps)>> summary;
|
||||
DateOnly? day;
|
||||
lock (_missesToday)
|
||||
{
|
||||
if (_missDay is null)
|
||||
{
|
||||
_missDay = today;
|
||||
return;
|
||||
}
|
||||
if (_missDay == today)
|
||||
return;
|
||||
|
||||
day = _missDay;
|
||||
summary = [.. _missesToday];
|
||||
_missesToday.Clear();
|
||||
_missDay = today;
|
||||
}
|
||||
|
||||
foreach (var (agent, counts) in summary)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Budget summary {Day:yyyy-MM-dd} for agent {AgentId}: {Misses} run(s) refused for lack of "
|
||||
+ "budget, {Carried} task(s) carried over, {CatchUps} catch-up run(s) scheduled",
|
||||
day, agent.Length == 0 ? "-" : agent, counts.Misses, counts.CarriedOver, counts.CatchUps);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Local midnight after <paramref name="nowUtc"/>, in UTC. The budget day follows the
|
||||
/// machine's local date, like <see cref="Budget.BudgetGuard"/>.
|
||||
/// </summary>
|
||||
private DateTime StartOfNextLocalDay(DateTime nowUtc)
|
||||
{
|
||||
var zone = _clock.LocalTimeZone;
|
||||
var local = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, zone);
|
||||
var midnight = DateTime.SpecifyKind(local.Date.AddDays(1), DateTimeKind.Unspecified);
|
||||
|
||||
// A midnight skipped by a DST change does not exist; take the next valid minute.
|
||||
while (zone.IsInvalidTime(midnight))
|
||||
midnight = midnight.AddMinutes(1);
|
||||
|
||||
return TimeZoneInfo.ConvertTimeToUtc(midnight, zone);
|
||||
}
|
||||
|
||||
private static string Iso(DateTime utc) => utc.ToUniversalTime().ToString("O");
|
||||
|
||||
/// <summary>
|
||||
/// Auto-Dispatch: Wird eine Aufgabe fertig, werden die auf sie wartenden Aufgaben
|
||||
/// freigegeben, sobald <b>alle</b> ihre Blocker erledigt sind.
|
||||
|
||||
@@ -201,6 +201,79 @@ public static class TaskSchedule
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #716: How many runs the task will still trigger from <paramref name="fromUtc"/> up to
|
||||
/// (excluding) <paramref name="toUtc"/> — including one that is due right now. The
|
||||
/// basis for "how much budget does the rest of the day still need".
|
||||
/// </summary>
|
||||
public static int OccurrencesBetween(TaskItem task, DateTime fromUtc, DateTime toUtc, int cap = 1_440)
|
||||
=> OccurrenceTimesBetween(task, fromUtc, toUtc, cap).Count;
|
||||
|
||||
/// <summary>
|
||||
/// #716: When the task will run from <paramref name="fromUtc"/> up to (excluding)
|
||||
/// <paramref name="toUtc"/>. A run due right now is listed at <paramref name="fromUtc"/>.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<DateTime> OccurrenceTimesBetween(TaskItem task, DateTime fromUtc, DateTime toUtc, int cap = 1_440)
|
||||
{
|
||||
fromUtc = DateTime.SpecifyKind(fromUtc, DateTimeKind.Utc);
|
||||
toUtc = DateTime.SpecifyKind(toUtc, DateTimeKind.Utc);
|
||||
var times = new List<DateTime>();
|
||||
if (toUtc <= fromUtc)
|
||||
return times;
|
||||
|
||||
var dueNow = DueOccurrence(task, fromUtc) is not null;
|
||||
if (dueNow)
|
||||
times.Add(fromUtc);
|
||||
|
||||
if (task.When is not { } when)
|
||||
return times; // one-time without a schedule: due until it ran
|
||||
|
||||
switch (when.Kind)
|
||||
{
|
||||
case TaskWhenKind.At:
|
||||
{
|
||||
if (!dueNow && task.LastOccurrence is null
|
||||
&& TryResolveInstant(when.Value, when.TimeZone, out var at) && at < toUtc)
|
||||
times.Add(at);
|
||||
return times;
|
||||
}
|
||||
|
||||
case TaskWhenKind.Every:
|
||||
{
|
||||
if (!TryParseInterval(when.Value, out var interval) || interval <= TimeSpan.Zero)
|
||||
return times;
|
||||
|
||||
var next = dueNow
|
||||
? fromUtc + interval
|
||||
: NextOccurrenceUtc(task, fromUtc) ?? toUtc;
|
||||
while (times.Count < cap && next < toUtc)
|
||||
{
|
||||
times.Add(next);
|
||||
next += interval;
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
case TaskWhenKind.Cron:
|
||||
{
|
||||
if (!TryPrepareCron(when, out var cron, out var tz))
|
||||
return times;
|
||||
|
||||
var cursor = fromUtc;
|
||||
while (times.Count < cap && NextCronUtc(cron, tz, cursor) is { } next && next < toUtc)
|
||||
{
|
||||
if (next > fromUtc)
|
||||
times.Add(next);
|
||||
cursor = next;
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
default:
|
||||
return times;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryPrepareCron(TaskWhen when, out CronExpression cron, out TimeZoneInfo tz)
|
||||
{
|
||||
cron = null!;
|
||||
|
||||
@@ -31,6 +31,10 @@ public sealed class TaskboardService
|
||||
public Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct)
|
||||
=> _repo.ListAsync(query, ct);
|
||||
|
||||
/// <summary>The whole board, without the cap of <see cref="ListAsync"/>.</summary>
|
||||
public Task<IReadOnlyList<TaskItem>> ListAllAsync(CancellationToken ct, bool includeArchived = false)
|
||||
=> _repo.ListAllAsync(includeArchived, ct);
|
||||
|
||||
// ─── Anlegen ───
|
||||
|
||||
/// <summary>
|
||||
@@ -104,9 +108,9 @@ public sealed class TaskboardService
|
||||
if (task is not null) { seen.Add(task.Id); imported++; }
|
||||
}
|
||||
|
||||
// Verwaiste Zeilen archivieren. Der Deckel von 500 ist für den Start unkritisch;
|
||||
// wächst das Board darüber hinaus, gehört ohnehin das Aufräumen (siehe Konzept) her.
|
||||
var known = await _repo.ListAsync(new TaskQuery { IncludeArchived = true, Limit = 500 }, ct);
|
||||
// Verwaiste Zeilen archivieren. Ungekappt (#716): Vorher sah dieser Abgleich nur die
|
||||
// ersten 500 Zeilen, und darüber blieben verwaiste Zeilen still stehen.
|
||||
var known = await _repo.ListAllAsync(includeArchived: true, ct);
|
||||
foreach (var task in known)
|
||||
if (task.Status != TaskItemStatus.Archived && !seen.Contains(task.Id))
|
||||
await _repo.SetStatusAsync(task.Id, TaskItemStatus.Archived, DateTime.UtcNow, ct);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Threading;
|
||||
using ClawdDotNet.App;
|
||||
@@ -77,6 +78,13 @@ public sealed partial class AgentItemViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private long _dailyTokens;
|
||||
|
||||
/// <summary>#716: What happens to a recurring task's run when the budget is exhausted.</summary>
|
||||
[ObservableProperty]
|
||||
private string _whenExhausted = "expire";
|
||||
|
||||
/// <summary>The values <see cref="AgentBudget.WhenExhausted"/> understands.</summary>
|
||||
public IReadOnlyList<string> WhenExhaustedOptions { get; } = ["expire", "catchUpOnce"];
|
||||
|
||||
public ObservableCollection<ToolItemViewModel> AvailableTools { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
@@ -101,6 +109,7 @@ public sealed partial class AgentItemViewModel : ViewModelBase
|
||||
_timeoutSeconds = config.LoopGuard.TimeoutSeconds;
|
||||
_dailyCostUsd = config.Budget.DailyCostUsd;
|
||||
_dailyTokens = config.Budget.DailyTokens;
|
||||
_whenExhausted = config.Budget.MissPolicy == BudgetMissPolicy.CatchUpOnce ? "catchUpOnce" : "expire";
|
||||
|
||||
foreach (var tool in KnownTools)
|
||||
{
|
||||
@@ -137,6 +146,7 @@ public sealed partial class AgentItemViewModel : ViewModelBase
|
||||
Config.LoopGuard.TimeoutSeconds = TimeoutSeconds;
|
||||
Config.Budget.DailyCostUsd = DailyCostUsd;
|
||||
Config.Budget.DailyTokens = DailyTokens;
|
||||
Config.Budget.WhenExhausted = WhenExhausted;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -208,6 +208,15 @@
|
||||
<TextBox Text="{Binding DailyTokens}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="{Binding L[desktop.agents.whenExhausted]}" />
|
||||
<ComboBox HorizontalAlignment="Stretch"
|
||||
ItemsSource="{Binding WhenExhaustedOptions}"
|
||||
SelectedItem="{Binding WhenExhausted}" />
|
||||
<TextBlock Classes="caption" TextWrapping="Wrap"
|
||||
Text="{Binding L[desktop.agents.whenExhaustedHint]}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"desktop.agents.maxToolResultChars": "Max. Tool-Ergebnis (Zeichen)",
|
||||
"desktop.agents.dailyCost": "Tagesbudget ($ USD)",
|
||||
"desktop.agents.dailyTokens": "Tagesbudget (Tokens)",
|
||||
"desktop.agents.whenExhausted": "Wenn das Budget erschöpft ist (wiederkehrende Aufgaben)",
|
||||
"desktop.agents.whenExhaustedHint": "expire: Der verpasste Lauf verfällt und wird an der Aufgabe vermerkt. catchUpOnce: Alle verpassten Läufe werden am nächsten Tag durch einen Lauf nachgeholt, nach der regulären Arbeit des Tages. Einmalige Aufgaben werden immer auf den nächsten Tag mitgenommen.",
|
||||
"desktop.agents.allowedToolsFor": "ZUGELASSENE WERKZEUGE FÜR {0}",
|
||||
"desktop.agents.thTool": "WERKZEUG",
|
||||
"desktop.agents.thDescription": "BESCHREIBUNG",
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
"desktop.agents.maxToolResultChars": "Max Tool Result (chars)",
|
||||
"desktop.agents.dailyCost": "Daily Budget ($ USD)",
|
||||
"desktop.agents.dailyTokens": "Daily Budget (Tokens)",
|
||||
"desktop.agents.whenExhausted": "When the budget is exhausted (recurring tasks)",
|
||||
"desktop.agents.whenExhaustedHint": "expire: the missed run lapses and is recorded on the task. catchUpOnce: all missed runs are caught up by one run the next day, after the day's regular work. One-time tasks are always carried over to the next day.",
|
||||
"desktop.agents.allowedToolsFor": "ALLOWED TOOLS FOR {0}",
|
||||
"desktop.agents.thTool": "TOOL",
|
||||
"desktop.agents.thDescription": "DESCRIPTION",
|
||||
|
||||
@@ -1721,3 +1721,77 @@ div.klein,
|
||||
td.klein {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ─── Team overview (#711) ─── */
|
||||
|
||||
.team-ansicht {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.team-zusammenfassung {
|
||||
flex-wrap: wrap;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.team-flaeche {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.team-graph {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 420px;
|
||||
background: var(--surface-subtil);
|
||||
border: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.team-seitenleiste {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
padding: 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.team-seitenleiste-kopf {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.team-seitenleiste h3 {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.team-ergebnis {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--schrift-mono);
|
||||
}
|
||||
|
||||
.team-aktionen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.team-legende {
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.team-flaeche { flex-direction: column; }
|
||||
.team-seitenleiste { width: auto; }
|
||||
}
|
||||
|
||||
@@ -35,10 +35,12 @@ import { renderUsageRuns } from "./views/usage-runs.js";
|
||||
import { renderUsageTimeline } from "./views/usage-timeline.js";
|
||||
import { renderRunDetail } from "./views/run-detail.js";
|
||||
import { renderPromptHistory } from "./views/prompt-history.js";
|
||||
import { renderTeam, leaveTeam } from "./views/team.js";
|
||||
|
||||
// ─── 1. Routen registrieren ───
|
||||
|
||||
function verlasseAnsicht() {
|
||||
leaveTeam();
|
||||
trenneLogStream();
|
||||
trenneAgentenChatsStream();
|
||||
stoppeInstanzenPolling();
|
||||
@@ -50,6 +52,7 @@ registriereRoute("/chat/:agentId", (p) => { verlasseAnsicht(); return rendereCha
|
||||
registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten(); });
|
||||
registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); });
|
||||
registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); });
|
||||
registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); });
|
||||
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
|
||||
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
|
||||
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
|
||||
|
||||
@@ -67,6 +67,28 @@
|
||||
"nav.chat": "Chat",
|
||||
"nav.agents": "Agenten",
|
||||
"nav.tasks": "Aufgaben",
|
||||
"nav.team": "Teamübersicht",
|
||||
"team.title": "Teamübersicht",
|
||||
"team.subtitle": "Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es. Ein Klick auf einen Agenten zeigt Einzelheiten.",
|
||||
"team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
|
||||
"team.empty": "Diese Instanz hat keine Agenten.",
|
||||
"team.legendLink": "hat erreicht (verblasst nach 30 s)",
|
||||
"team.state.Idle": "bereit",
|
||||
"team.state.Working": "arbeitet",
|
||||
"team.state.Stuck": "hängt",
|
||||
"team.state.WaitingForApproval": "wartet auf Freigabe",
|
||||
"team.state.Problem": "Problem",
|
||||
"team.state.BudgetExhausted": "Budget erschöpft",
|
||||
"team.running": "Läuft gerade",
|
||||
"team.since": "seit {time}",
|
||||
"team.task": "Aufgabe {id}",
|
||||
"team.calledBy": "gerufen von {agent}",
|
||||
"team.stuckHint": "Läufe dieses Agenten dauern sonst etwa {minutes} Min.",
|
||||
"team.approvals": "{n} Werkzeugaufruf/-aufrufe warten auf Freigabe",
|
||||
"team.lastRun": "Letzter Lauf",
|
||||
"team.openApprovals": "Freigaben öffnen",
|
||||
"team.openChat": "Chat öffnen",
|
||||
"team.openRuns": "Läufe anzeigen",
|
||||
"nav.services": "Dienste",
|
||||
"nav.group.analytics": "Analyse",
|
||||
"nav.usage": "Tokennutzung",
|
||||
@@ -192,6 +214,12 @@
|
||||
"tasks.saved": "Aufgabe gespeichert.",
|
||||
"tasks.deleted": "Aufgabe gelöscht.",
|
||||
"tasks.runTriggered": "Lauf ausgelöst.",
|
||||
"tasks.nextRun": "Nächster",
|
||||
"tasks.lastRun": "Letzter",
|
||||
"tasks.budgetMissed": "Budget erschöpft: {n} Lauf/Läufe abgelehnt",
|
||||
"tasks.budgetMissedLast": "zuletzt {time}",
|
||||
"tasks.budgetCarriedOver": "mitgenommen, wieder fällig ab {time}",
|
||||
"tasks.budgetCatchUp": "ein Nachholauf ab {time}",
|
||||
|
||||
"services.title": "Dienste",
|
||||
"services.newService": "Neuer Dienst",
|
||||
|
||||
@@ -67,6 +67,28 @@
|
||||
"nav.chat": "Chat",
|
||||
"nav.agents": "Agents",
|
||||
"nav.tasks": "Tasks",
|
||||
"nav.team": "Team overview",
|
||||
"team.title": "Team overview",
|
||||
"team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Click an agent for details.",
|
||||
"team.noInstance": "No instance selected. Choose an active instance in the header.",
|
||||
"team.empty": "No agents in this instance.",
|
||||
"team.legendLink": "reached (fades after 30 s)",
|
||||
"team.state.Idle": "idle",
|
||||
"team.state.Working": "working",
|
||||
"team.state.Stuck": "stuck",
|
||||
"team.state.WaitingForApproval": "waiting for approval",
|
||||
"team.state.Problem": "problem",
|
||||
"team.state.BudgetExhausted": "budget exhausted",
|
||||
"team.running": "Running now",
|
||||
"team.since": "since {time}",
|
||||
"team.task": "task {id}",
|
||||
"team.calledBy": "called by {agent}",
|
||||
"team.stuckHint": "Runs of this agent usually take about {minutes} min.",
|
||||
"team.approvals": "{n} tool call(s) waiting for approval",
|
||||
"team.lastRun": "Last run",
|
||||
"team.openApprovals": "Open approvals",
|
||||
"team.openChat": "Open chat",
|
||||
"team.openRuns": "Show runs",
|
||||
"nav.services": "Services",
|
||||
"nav.group.analytics": "Analytics",
|
||||
"nav.usage": "Token Usage",
|
||||
@@ -192,6 +214,12 @@
|
||||
"tasks.saved": "Task saved.",
|
||||
"tasks.deleted": "Task deleted.",
|
||||
"tasks.runTriggered": "Run triggered.",
|
||||
"tasks.nextRun": "Next",
|
||||
"tasks.lastRun": "Last",
|
||||
"tasks.budgetMissed": "Budget exhausted: {n} run(s) refused",
|
||||
"tasks.budgetMissedLast": "last at {time}",
|
||||
"tasks.budgetCarriedOver": "carried over, due again from {time}",
|
||||
"tasks.budgetCatchUp": "one catch-up run from {time}",
|
||||
|
||||
"services.title": "Services",
|
||||
"services.newService": "New Service",
|
||||
|
||||
@@ -55,6 +55,12 @@
|
||||
</svg>
|
||||
<span class="nav-label" data-i18n="nav.agents">Agents</span>
|
||||
</a>
|
||||
<a href="#/team" class="nav-link" title="Team overview" data-i18n-title="nav.team">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M3 3h6v6H3z M15 3h6v6h-6z M9 15h6v6H9z M6 9v3h12V9 M12 12v3"/>
|
||||
</svg>
|
||||
<span class="nav-label" data-i18n="nav.team">Team overview</span>
|
||||
</a>
|
||||
<a href="#/aufgaben" class="nav-link" title="Tasks" data-i18n-title="nav.tasks">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
|
||||
|
||||
@@ -93,7 +93,7 @@ export function verbindeStrom() {
|
||||
meldeStatus(false);
|
||||
};
|
||||
|
||||
const bekannteEreignisse = ["chat", "step", "run", "instance", "status"];
|
||||
const bekannteEreignisse = ["chat", "step", "run", "instance", "status", "activity"];
|
||||
for (const art of bekannteEreignisse) {
|
||||
strom.addEventListener(art, (e) => verteileEreignis(art, e.data));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Vendored libraries
|
||||
|
||||
Third-party files the WebUI ships as-is. They live here instead of being loaded from a CDN
|
||||
because the target machine may have no internet access (WebUI-Leitfaden). Do not edit them;
|
||||
to update, replace the file and this table together.
|
||||
|
||||
Downloaded 2026-09-24 from the npm registry. Each tarball was checked against the
|
||||
`dist.integrity` (SHA-512) the registry publishes before extracting.
|
||||
|
||||
| Package | Version | File | License | SHA-256 of the file |
|
||||
|---|---|---|---|---|
|
||||
| [cytoscape](https://www.npmjs.com/package/cytoscape) | 3.34.3 | `cytoscape/cytoscape.min.js` | MIT | `5f3b5b529546d5af1fc5628590af033b74511a5b6f789f5f4682845863228b91` |
|
||||
| [elkjs](https://www.npmjs.com/package/elkjs) | 0.9.3 | `elkjs/elk.bundled.js` | EPL-2.0 | `b0745abd7f23cd91690a1587e377edbe19fd7233c783300290936720546216d4` |
|
||||
| [cytoscape-elk](https://www.npmjs.com/package/cytoscape-elk) | 2.3.0 | `cytoscape-elk/cytoscape-elk.js` | MIT | `25acbb9dea9b0dc6fef0e79b697547ee56e579f0e1c22ae4353131e72c7e140a` |
|
||||
|
||||
**Why elkjs 0.9.3 and not the newest.** cytoscape-elk 2.3.0 declares `elkjs ^0.9.3`. A newer
|
||||
elkjs would be untested with the adapter.
|
||||
|
||||
**How they are loaded.** All three are UMD bundles and, loaded as classic scripts in this
|
||||
order, register the globals `cytoscape`, `ELK` and `cytoscapeElk`. The team overview (#711)
|
||||
loads them on demand, so pages that do not draw a graph do not pay for ~2 MB of script.
|
||||
|
||||
The license texts sit next to each file.
|
||||
@@ -0,0 +1,21 @@
|
||||
|
||||
|
||||
Copyright (c) 2016-2018, 2020, The Cytoscape Consortium.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the “Software”), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,356 @@
|
||||
(function webpackUniversalModuleDefinition(root, factory) {
|
||||
if(typeof exports === 'object' && typeof module === 'object')
|
||||
module.exports = factory(require("elkjs/lib/elk.bundled.js"));
|
||||
else if(typeof define === 'function' && define.amd)
|
||||
define(["elkjs"], factory);
|
||||
else if(typeof exports === 'object')
|
||||
exports["cytoscapeElk"] = factory(require("elkjs/lib/elk.bundled.js"));
|
||||
else
|
||||
root["cytoscapeElk"] = factory(root["ELK"]);
|
||||
})(this, function(__WEBPACK_EXTERNAL_MODULE__632__) {
|
||||
return /******/ (function() { // webpackBootstrap
|
||||
/******/ "use strict";
|
||||
/******/ var __webpack_modules__ = ({
|
||||
|
||||
/***/ 632:
|
||||
/***/ (function(module) {
|
||||
|
||||
module.exports = __WEBPACK_EXTERNAL_MODULE__632__;
|
||||
|
||||
/***/ })
|
||||
|
||||
/******/ });
|
||||
/************************************************************************/
|
||||
/******/ // The module cache
|
||||
/******/ var __webpack_module_cache__ = {};
|
||||
/******/
|
||||
/******/ // The require function
|
||||
/******/ function __webpack_require__(moduleId) {
|
||||
/******/ // Check if module is in cache
|
||||
/******/ var cachedModule = __webpack_module_cache__[moduleId];
|
||||
/******/ if (cachedModule !== undefined) {
|
||||
/******/ return cachedModule.exports;
|
||||
/******/ }
|
||||
/******/ // Create a new module (and put it into the cache)
|
||||
/******/ var module = __webpack_module_cache__[moduleId] = {
|
||||
/******/ // no module.id needed
|
||||
/******/ // no module.loaded needed
|
||||
/******/ exports: {}
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // Execute the module function
|
||||
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
|
||||
/******/
|
||||
/******/ // Return the exports of the module
|
||||
/******/ return module.exports;
|
||||
/******/ }
|
||||
/******/
|
||||
/************************************************************************/
|
||||
/******/ /* webpack/runtime/compat get default export */
|
||||
/******/ !function() {
|
||||
/******/ // getDefaultExport function for compatibility with non-harmony modules
|
||||
/******/ __webpack_require__.n = function(module) {
|
||||
/******/ var getter = module && module.__esModule ?
|
||||
/******/ function() { return module['default']; } :
|
||||
/******/ function() { return module; };
|
||||
/******/ __webpack_require__.d(getter, { a: getter });
|
||||
/******/ return getter;
|
||||
/******/ };
|
||||
/******/ }();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/define property getters */
|
||||
/******/ !function() {
|
||||
/******/ // define getter functions for harmony exports
|
||||
/******/ __webpack_require__.d = function(exports, definition) {
|
||||
/******/ for(var key in definition) {
|
||||
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
|
||||
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
|
||||
/******/ }
|
||||
/******/ }
|
||||
/******/ };
|
||||
/******/ }();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
||||
/******/ !function() {
|
||||
/******/ __webpack_require__.o = function(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); }
|
||||
/******/ }();
|
||||
/******/
|
||||
/************************************************************************/
|
||||
var __webpack_exports__ = {};
|
||||
// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk.
|
||||
!function() {
|
||||
|
||||
// EXPORTS
|
||||
__webpack_require__.d(__webpack_exports__, {
|
||||
"default": function() { return /* binding */ src; }
|
||||
});
|
||||
|
||||
// EXTERNAL MODULE: external {"commonjs":"elkjs/lib/elk.bundled.js","commonjs2":"elkjs/lib/elk.bundled.js","amd":"elkjs","root":"ELK"}
|
||||
var elk_bundled_js_amd_elkjs_root_ELK_ = __webpack_require__(632);
|
||||
var elk_bundled_js_amd_elkjs_root_ELK_default = /*#__PURE__*/__webpack_require__.n(elk_bundled_js_amd_elkjs_root_ELK_);
|
||||
;// CONCATENATED MODULE: ./src/assign.js
|
||||
// Simple, internal Object.assign() polyfill for options objects etc.
|
||||
function assign_assign(tgt) {
|
||||
for (var _len = arguments.length, srcs = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
|
||||
srcs[_key - 1] = arguments[_key];
|
||||
}
|
||||
|
||||
srcs.forEach(function (src) {
|
||||
Object.keys(src).forEach(function (k) {
|
||||
return tgt[k] = src[k];
|
||||
});
|
||||
});
|
||||
return tgt;
|
||||
}
|
||||
|
||||
/* harmony default export */ var src_assign = (Object.assign != null ? Object.assign.bind(Object) : assign_assign);
|
||||
;// CONCATENATED MODULE: ./src/defaults.js
|
||||
var defaults = {
|
||||
nodeDimensionsIncludeLabels: false,
|
||||
// Boolean which changes whether label dimensions are included when calculating node dimensions
|
||||
fit: true,
|
||||
// Whether to fit
|
||||
padding: 20,
|
||||
// Padding on fit
|
||||
animate: false,
|
||||
// Whether to transition the node positions
|
||||
animateFilter: function animateFilter() {
|
||||
return true;
|
||||
},
|
||||
// Whether to animate specific nodes when animation is on; non-animated nodes immediately go to their final positions
|
||||
animationDuration: 500,
|
||||
// Duration of animation in ms if enabled
|
||||
animationEasing: undefined,
|
||||
// Easing of animation if enabled
|
||||
transform: function transform(node, pos) {
|
||||
return pos;
|
||||
},
|
||||
// A function that applies a transform to the final node position
|
||||
ready: undefined,
|
||||
// Callback on layoutready
|
||||
stop: undefined,
|
||||
// Callback on layoutstop
|
||||
nodeLayoutOptions: undefined,
|
||||
// Special options for only the nodes
|
||||
elk: {
|
||||
// Options to pass directly to ELK `layoutOptions`. The subsequent identifier has to be used as property key in quotes.
|
||||
// E.g. for 'org.eclipse.elk.direction' use:
|
||||
// 'elk.direction'
|
||||
// Primary/mandatory, the elk algorithm to use
|
||||
// one of 'box', 'disco', 'force', 'layered', 'mrtree', 'radial', 'random', 'stress'
|
||||
// (see https://www.eclipse.org/elk/reference/algorithms.html)
|
||||
algorithm: undefined
|
||||
},
|
||||
priority: function priority() {
|
||||
return null;
|
||||
} // Edges with a non-nil value are skipped when geedy edge cycle breaking is enabled
|
||||
|
||||
};
|
||||
/* harmony default export */ var src_defaults = (defaults);
|
||||
;// CONCATENATED MODULE: ./src/layout.js
|
||||
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
|
||||
|
||||
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
|
||||
|
||||
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
|
||||
|
||||
|
||||
|
||||
|
||||
var elkOverrides = {};
|
||||
|
||||
var getPos = function getPos(ele, options) {
|
||||
var dims = ele.layoutDimensions(options);
|
||||
var parent = ele.parent();
|
||||
var k = ele.scratch('elk');
|
||||
var p = {
|
||||
x: k.x,
|
||||
y: k.y
|
||||
};
|
||||
|
||||
while (parent.nonempty()) {
|
||||
var kp = parent.scratch('elk');
|
||||
p.x += kp.x;
|
||||
p.y += kp.y;
|
||||
parent = parent.parent();
|
||||
} // elk considers a node position to be its top-left corner, while cy is the centre
|
||||
|
||||
|
||||
p.x += dims.w / 2;
|
||||
p.y += dims.h / 2;
|
||||
return p;
|
||||
};
|
||||
|
||||
var makeNode = function makeNode(node, options) {
|
||||
var k = {
|
||||
_cyEle: node,
|
||||
id: node.id()
|
||||
}; // Apply nodeLayoutOptions per user-specified function
|
||||
// e.g. nodeLayoutOptions => n.scratch('layoutOptions')
|
||||
|
||||
if (options.nodeLayoutOptions) {
|
||||
k.layoutOptions = options.nodeLayoutOptions(node);
|
||||
}
|
||||
|
||||
if (!node.isParent()) {
|
||||
var dims = node.layoutDimensions(options);
|
||||
var p = node.position(); // the elk position is the top-left corner, cy is the centre
|
||||
|
||||
k.x = p.x - dims.w / 2;
|
||||
k.y = p.y - dims.h / 2;
|
||||
k.width = dims.w;
|
||||
k.height = dims.h;
|
||||
}
|
||||
|
||||
node.scratch('elk', k);
|
||||
return k;
|
||||
};
|
||||
|
||||
var makeEdge = function makeEdge(edge
|
||||
/*, options*/
|
||||
) {
|
||||
var k = {
|
||||
_cyEle: edge,
|
||||
id: edge.id(),
|
||||
source: edge.data('source'),
|
||||
target: edge.data('target')
|
||||
};
|
||||
edge.scratch('elk', k);
|
||||
return k;
|
||||
};
|
||||
|
||||
var makeGraph = function makeGraph(nodes, edges, options) {
|
||||
var elkNodes = [];
|
||||
var elkEdges = [];
|
||||
var elkEleLookup = {};
|
||||
var graph = {
|
||||
id: 'root',
|
||||
children: [],
|
||||
edges: []
|
||||
}; // map all nodes
|
||||
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var n = nodes[i];
|
||||
var k = makeNode(n, options);
|
||||
elkNodes.push(k);
|
||||
elkEleLookup[n.id()] = k;
|
||||
} // map all edges
|
||||
|
||||
|
||||
for (var _i = 0; _i < edges.length; _i++) {
|
||||
var e = edges[_i];
|
||||
|
||||
var _k = makeEdge(e, options);
|
||||
|
||||
elkEdges.push(_k);
|
||||
elkEleLookup[e.id()] = _k;
|
||||
} // make hierarchy
|
||||
|
||||
|
||||
for (var _i2 = 0; _i2 < elkNodes.length; _i2++) {
|
||||
var _k2 = elkNodes[_i2];
|
||||
var _n = _k2._cyEle;
|
||||
|
||||
if (!_n.isChild()) {
|
||||
graph.children.push(_k2);
|
||||
} else {
|
||||
var parent = _n.parent();
|
||||
|
||||
var parentK = elkEleLookup[parent.id()];
|
||||
var children = parentK.children = parentK.children || [];
|
||||
children.push(_k2);
|
||||
}
|
||||
}
|
||||
|
||||
for (var _i3 = 0; _i3 < elkEdges.length; _i3++) {
|
||||
var _k3 = elkEdges[_i3]; // put all edges in the top level for now
|
||||
// TODO does this cause issues in certain edgecases?
|
||||
|
||||
/*let e = k._cyEle;
|
||||
let parentSrc = e.source().parent();
|
||||
let parentTgt = e.target().parent();
|
||||
if ( false && parentSrc.nonempty() && parentTgt.nonempty() && parentSrc.same( parentTgt ) ){
|
||||
let kp = elkEleLookup[ parentSrc.id() ];
|
||||
kp.edges = kp.edges || [];
|
||||
kp.edges.push( k );
|
||||
} else {*/
|
||||
|
||||
graph.edges.push(_k3); //}
|
||||
}
|
||||
|
||||
return graph;
|
||||
};
|
||||
|
||||
var Layout = /*#__PURE__*/function () {
|
||||
function Layout(options) {
|
||||
_classCallCheck(this, Layout);
|
||||
|
||||
var elkOptions = options.elk;
|
||||
var cy = options.cy;
|
||||
this.options = src_assign({}, src_defaults, options);
|
||||
this.options.elk = src_assign({
|
||||
aspectRatio: cy.width() / cy.height()
|
||||
}, src_defaults.elk, elkOptions, elkOverrides);
|
||||
}
|
||||
|
||||
_createClass(Layout, [{
|
||||
key: "run",
|
||||
value: function run() {
|
||||
var layout = this;
|
||||
var options = this.options;
|
||||
var eles = options.eles;
|
||||
var nodes = eles.nodes();
|
||||
var edges = eles.edges();
|
||||
var elk = new (elk_bundled_js_amd_elkjs_root_ELK_default())();
|
||||
var graph = makeGraph(nodes, edges, options);
|
||||
graph['layoutOptions'] = options.elk;
|
||||
elk.layout(graph).then(function () {
|
||||
nodes.filter(function (n) {
|
||||
return !n.isParent();
|
||||
}).layoutPositions(layout, options, function (n) {
|
||||
return getPos(n, options);
|
||||
});
|
||||
});
|
||||
return this;
|
||||
}
|
||||
}, {
|
||||
key: "stop",
|
||||
value: function stop() {
|
||||
return this; // chaining
|
||||
}
|
||||
}, {
|
||||
key: "destroy",
|
||||
value: function destroy() {
|
||||
return this; // chaining
|
||||
}
|
||||
}]);
|
||||
|
||||
return Layout;
|
||||
}();
|
||||
|
||||
/* harmony default export */ var layout = (Layout);
|
||||
;// CONCATENATED MODULE: ./src/index.js
|
||||
// registers the extension on a cytoscape lib ref
|
||||
|
||||
var register = function register(cytoscape) {
|
||||
if (!cytoscape) {
|
||||
return;
|
||||
} // can't register if cytoscape unspecified
|
||||
|
||||
|
||||
cytoscape('layout', 'elk', layout); // register with cytoscape.js
|
||||
};
|
||||
|
||||
if (typeof cytoscape !== 'undefined') {
|
||||
// expose to global cytoscape (i.e. window.cytoscape)
|
||||
// eslint-disable-next-line no-undef
|
||||
register(cytoscape);
|
||||
}
|
||||
|
||||
/* harmony default export */ var src = (register);
|
||||
}();
|
||||
__webpack_exports__ = __webpack_exports__["default"];
|
||||
/******/ return __webpack_exports__;
|
||||
/******/ })()
|
||||
;
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2016-2026, The Cytoscape Consortium.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the “Software”), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,264 @@
|
||||
# Eclipse Public License - v 2.0
|
||||
|
||||
THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
|
||||
PUBLIC LICENSE (“AGREEMENT”). ANY USE, REPRODUCTION OR DISTRIBUTION OF
|
||||
THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
|
||||
|
||||
## 1. DEFINITIONS
|
||||
|
||||
“Contribution” means:
|
||||
|
||||
- a\) in the case of the initial Contributor, the initial content
|
||||
Distributed under this Agreement, and
|
||||
- b\) in the case of each subsequent Contributor:
|
||||
- i\) changes to the Program, and
|
||||
- ii\) additions to the Program;
|
||||
|
||||
where such changes and/or additions to the Program originate from
|
||||
and are Distributed by that particular Contributor. A Contribution
|
||||
“originates” from a Contributor if it was added to the Program by
|
||||
such Contributor itself or anyone acting on such Contributor's
|
||||
behalf. Contributions do not include changes or additions to the
|
||||
Program that are not Modified Works.
|
||||
|
||||
“Contributor” means any person or entity that Distributes the Program.
|
||||
|
||||
“Licensed Patents” mean patent claims licensable by a Contributor which
|
||||
are necessarily infringed by the use or sale of its Contribution alone
|
||||
or when combined with the Program.
|
||||
|
||||
“Program” means the Contributions Distributed in accordance with this
|
||||
Agreement.
|
||||
|
||||
“Recipient” means anyone who receives the Program under this Agreement
|
||||
or any Secondary License (as applicable), including Contributors.
|
||||
|
||||
“Derivative Works” shall mean any work, whether in Source Code or other
|
||||
form, that is based on (or derived from) the Program and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship.
|
||||
|
||||
“Modified Works” shall mean any work in Source Code or other form that
|
||||
results from an addition to, deletion from, or modification of the
|
||||
contents of the Program, including, for purposes of clarity any new file
|
||||
in Source Code form that contains any contents of the Program. Modified
|
||||
Works shall not include works that contain only declarations,
|
||||
interfaces, types, classes, structures, or files of the Program solely
|
||||
in each case in order to link to, bind by name, or subclass the Program
|
||||
or Modified Works thereof.
|
||||
|
||||
“Distribute” means the acts of a) distributing or b) making available in
|
||||
any manner that enables the transfer of a copy.
|
||||
|
||||
“Source Code” means the form of a Program preferred for making
|
||||
modifications, including but not limited to software source code,
|
||||
documentation source, and configuration files.
|
||||
|
||||
“Secondary License” means either the GNU General Public License, Version
|
||||
2.0, or any later versions of that license, including any exceptions or
|
||||
additional permissions as identified by the initial Contributor.
|
||||
|
||||
## 2. GRANT OF RIGHTS
|
||||
|
||||
- a\) Subject to the terms of this Agreement, each Contributor hereby
|
||||
grants Recipient a non-exclusive, worldwide, royalty-free copyright
|
||||
license to reproduce, prepare Derivative Works of, publicly display,
|
||||
publicly perform, Distribute and sublicense the Contribution of such
|
||||
Contributor, if any, and such Derivative Works.
|
||||
- b\) Subject to the terms of this Agreement, each Contributor hereby
|
||||
grants Recipient a non-exclusive, worldwide, royalty-free patent license
|
||||
under Licensed Patents to make, use, sell, offer to sell, import and
|
||||
otherwise transfer the Contribution of such Contributor, if any, in
|
||||
Source Code or other form. This patent license shall apply to the
|
||||
combination of the Contribution and the Program if, at the time the
|
||||
Contribution is added by the Contributor, such addition of the
|
||||
Contribution causes such combination to be covered by the
|
||||
Licensed Patents. The patent license shall not apply to any other
|
||||
combinations which include the Contribution. No hardware per se is
|
||||
licensed hereunder.
|
||||
- c\) Recipient understands that although each Contributor grants the
|
||||
licenses to its Contributions set forth herein, no assurances are
|
||||
provided by any Contributor that the Program does not infringe the
|
||||
patent or other intellectual property rights of any other entity. Each
|
||||
Contributor disclaims any liability to Recipient for claims brought by
|
||||
any other entity based on infringement of intellectual property rights
|
||||
or otherwise. As a condition to exercising the rights and licenses
|
||||
granted hereunder, each Recipient hereby assumes sole responsibility to
|
||||
secure any other intellectual property rights needed, if any. For
|
||||
example, if a third party patent license is required to allow Recipient
|
||||
to Distribute the Program, it is Recipient's responsibility to acquire
|
||||
that license before distributing the Program.
|
||||
- d\) Each Contributor represents that to its knowledge it has sufficient
|
||||
copyright rights in its Contribution, if any, to grant the copyright
|
||||
license set forth in this Agreement.
|
||||
- e\) Notwithstanding the terms of any Secondary License, no Contributor
|
||||
makes additional grants to any Recipient (other than those set forth in
|
||||
this Agreement) as a result of such Recipient's receipt of the Program
|
||||
under the terms of a Secondary License (if permitted under the terms of
|
||||
Section 3).
|
||||
|
||||
## 3. REQUIREMENTS
|
||||
|
||||
3.1 If a Contributor Distributes the Program in any form, then:
|
||||
|
||||
- a\) the Program must also be made available as Source Code, in accordance
|
||||
with section 3.2, and the Contributor must accompany the Program with a
|
||||
statement that the Source Code for the Program is available under this
|
||||
Agreement, and informs Recipients how to obtain it in a reasonable
|
||||
manner on or through a medium customarily used for software exchange;
|
||||
and
|
||||
- b\) the Contributor may Distribute the Program under a license different
|
||||
than this Agreement, provided that such license:
|
||||
- i\) effectively disclaims on behalf of all other Contributors all
|
||||
warranties and conditions, express and implied, including warranties or
|
||||
conditions of title and non-infringement, and implied warranties or
|
||||
conditions of merchantability and fitness for a particular purpose;
|
||||
- ii\) effectively excludes on behalf of all other Contributors all
|
||||
liability for damages, including direct, indirect, special, incidental
|
||||
and consequential damages, such as lost profits;
|
||||
- iii\) does not attempt to limit or alter the recipients' rights in the
|
||||
Source Code under section 3.2; and
|
||||
- iv\) requires any subsequent distribution of the Program by any party to
|
||||
be under a license that satisfies the requirements of this section 3.
|
||||
|
||||
3.2 When the Program is Distributed as Source Code:
|
||||
|
||||
- a\) it must be made available under this Agreement, or if the Program (i)
|
||||
is combined with other material in a separate file or files made
|
||||
available under a Secondary License, and (ii) the initial Contributor
|
||||
attached to the Source Code the notice described in Exhibit A of this
|
||||
Agreement, then the Program may be made available under the terms of
|
||||
such Secondary Licenses, and
|
||||
- b\) a copy of this Agreement must be included with each copy of
|
||||
the Program.
|
||||
|
||||
3.3 Contributors may not remove or alter any copyright, patent,
|
||||
trademark, attribution notices, disclaimers of warranty, or limitations
|
||||
of liability (‘notices’) contained within the Program from any copy of
|
||||
the Program which they Distribute, provided that Contributors may add
|
||||
their own appropriate notices.
|
||||
|
||||
## 4. COMMERCIAL DISTRIBUTION
|
||||
|
||||
Commercial distributors of software may accept certain responsibilities
|
||||
with respect to end users, business partners and the like. While this
|
||||
license is intended to facilitate the commercial use of the Program, the
|
||||
Contributor who includes the Program in a commercial product offering
|
||||
should do so in a manner which does not create potential liability for
|
||||
other Contributors. Therefore, if a Contributor includes the Program in
|
||||
a commercial product offering, such Contributor (“Commercial
|
||||
Contributor”) hereby agrees to defend and indemnify every other
|
||||
Contributor (“Indemnified Contributor”) against any losses, damages and
|
||||
costs (collectively “Losses”) arising from claims, lawsuits and other
|
||||
legal actions brought by a third party against the Indemnified
|
||||
Contributor to the extent caused by the acts or omissions of such
|
||||
Commercial Contributor in connection with its distribution of the
|
||||
Program in a commercial product offering. The obligations in this
|
||||
section do not apply to any claims or Losses relating to any actual or
|
||||
alleged intellectual property infringement. In order to qualify, an
|
||||
Indemnified Contributor must: a) promptly notify the Commercial
|
||||
Contributor in writing of such claim, and b) allow the Commercial
|
||||
Contributor to control, and cooperate with the Commercial Contributor
|
||||
in, the defense and any related settlement negotiations. The Indemnified
|
||||
Contributor may participate in any such claim at its own expense.
|
||||
|
||||
For example, a Contributor might include the Program in a commercial
|
||||
product offering, Product X. That Contributor is then a Commercial
|
||||
Contributor. If that Commercial Contributor then makes performance
|
||||
claims, or offers warranties related to Product X, those performance
|
||||
claims and warranties are such Commercial Contributor's responsibility
|
||||
alone. Under this section, the Commercial Contributor would have to
|
||||
defend claims against the other Contributors related to those
|
||||
performance claims and warranties, and if a court requires any other
|
||||
Contributor to pay any damages as a result, the Commercial Contributor
|
||||
must pay those damages.
|
||||
|
||||
## 5. NO WARRANTY {#warranty}
|
||||
|
||||
EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
|
||||
PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN “AS IS”
|
||||
BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR
|
||||
IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF
|
||||
TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR
|
||||
PURPOSE. Each Recipient is solely responsible for determining the
|
||||
appropriateness of using and distributing the Program and assumes all
|
||||
risks associated with its exercise of rights under this Agreement,
|
||||
including but not limited to the risks and costs of program errors,
|
||||
compliance with applicable laws, damage to or loss of data, programs or
|
||||
equipment, and unavailability or interruption of operations.
|
||||
|
||||
## 6. DISCLAIMER OF LIABILITY {#disclaimer}
|
||||
|
||||
EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
|
||||
PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS
|
||||
SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST
|
||||
PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE
|
||||
EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
## 7. GENERAL
|
||||
|
||||
If any provision of this Agreement is invalid or unenforceable under
|
||||
applicable law, it shall not affect the validity or enforceability of
|
||||
the remainder of the terms of this Agreement, and without further action
|
||||
by the parties hereto, such provision shall be reformed to the minimum
|
||||
extent necessary to make such provision valid and enforceable.
|
||||
|
||||
If Recipient institutes patent litigation against any entity (including
|
||||
a cross-claim or counterclaim in a lawsuit) alleging that the Program
|
||||
itself (excluding combinations of the Program with other software or
|
||||
hardware) infringes such Recipient's patent(s), then such Recipient's
|
||||
rights granted under Section 2(b) shall terminate as of the date such
|
||||
litigation is filed.
|
||||
|
||||
All Recipient's rights under this Agreement shall terminate if it fails
|
||||
to comply with any of the material terms or conditions of this Agreement
|
||||
and does not cure such failure in a reasonable period of time after
|
||||
becoming aware of such noncompliance. If all Recipient's rights under
|
||||
this Agreement terminate, Recipient agrees to cease use and distribution
|
||||
of the Program as soon as reasonably practicable. However, Recipient's
|
||||
obligations under this Agreement and any licenses granted by Recipient
|
||||
relating to the Program shall continue and survive.
|
||||
|
||||
Everyone is permitted to copy and distribute copies of this Agreement,
|
||||
but in order to avoid inconsistency the Agreement is copyrighted and may
|
||||
only be modified in the following manner. The Agreement Steward reserves
|
||||
the right to publish new versions (including revisions) of this
|
||||
Agreement from time to time. No one other than the Agreement Steward has
|
||||
the right to modify this Agreement. The Eclipse Foundation is the
|
||||
initial Agreement Steward. The Eclipse Foundation may assign the
|
||||
responsibility to serve as the Agreement Steward to a suitable separate
|
||||
entity. Each new version of the Agreement will be given a distinguishing
|
||||
version number. The Program (including Contributions) may always be
|
||||
Distributed subject to the version of the Agreement under which it was
|
||||
received. In addition, after a new version of the Agreement is
|
||||
published, Contributor may elect to Distribute the Program (including
|
||||
its Contributions) under the new version.
|
||||
|
||||
Except as expressly stated in Sections 2(a) and 2(b) above, Recipient
|
||||
receives no rights or licenses to the intellectual property of any
|
||||
Contributor under this Agreement, whether expressly, by implication,
|
||||
estoppel or otherwise. All rights in the Program not expressly granted
|
||||
under this Agreement are reserved. Nothing in this Agreement is intended
|
||||
to be enforceable by any entity that is not a Contributor or Recipient.
|
||||
No third-party beneficiary rights are created under this Agreement.
|
||||
|
||||
## Exhibit A – Form of Secondary Licenses Notice {#exhibit-a}
|
||||
|
||||
“This Source Code may also be made available under the following
|
||||
Secondary Licenses when the conditions for such availability set forth
|
||||
in the Eclipse Public License, v. 2.0 are satisfied: {name license(s),
|
||||
version(s), and exceptions or additional permissions here}.”
|
||||
|
||||
> Simply including a copy of this Agreement, including this Exhibit A is
|
||||
> not sufficient to license the Source Code under Secondary Licenses.
|
||||
>
|
||||
> If it is not possible or desirable to put the notice in a particular
|
||||
> file, then You may include the notice in a location (such as a LICENSE
|
||||
> file in a relevant directory) where a recipient would be likely to
|
||||
> look for such a notice.
|
||||
>
|
||||
> You may add additional accurate notices of copyright ownership.
|
||||
File diff suppressed because one or more lines are too long
@@ -163,11 +163,11 @@ function erstelleAufgabenKarte(task) {
|
||||
zeilen.push(
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft text-klein",
|
||||
text: `Next: ${task.nextRunUtc ? formatiereDatumZeit(task.nextRunUtc) : "—"}`
|
||||
text: `${t("tasks.nextRun")}: ${task.nextRunUtc ? formatiereDatumZeit(task.nextRunUtc) : "—"}`
|
||||
})
|
||||
);
|
||||
|
||||
const letzter = erstelleElement("p", { klasse: "gedaempft text-klein" }, "Last: ");
|
||||
const letzter = erstelleElement("p", { klasse: "gedaempft text-klein" }, `${t("tasks.lastRun")}: `);
|
||||
if (task.lastRun) {
|
||||
letzter.append(
|
||||
`${formatiereDatumZeit(task.lastRun.occurredAt)} `,
|
||||
@@ -184,6 +184,19 @@ function erstelleAufgabenKarte(task) {
|
||||
zeilen.push(erstelleElement("p", { klasse: "text-klein", text: `⚠ ${task.hinweis}` }));
|
||||
}
|
||||
|
||||
// #716: What the budget refused, and what happens next.
|
||||
const ausfall = task.budgetMiss;
|
||||
if (ausfall) {
|
||||
const teile = [t("tasks.budgetMissed", { n: ausfall.count })];
|
||||
if (ausfall.lastAt) teile.push(t("tasks.budgetMissedLast", { time: formatiereDatumZeit(ausfall.lastAt) }));
|
||||
if (ausfall.carriedOver && ausfall.deferredUntil) {
|
||||
teile.push(t("tasks.budgetCarriedOver", { time: formatiereDatumZeit(ausfall.deferredUntil) }));
|
||||
} else if (ausfall.catchUpPending) {
|
||||
teile.push(t("tasks.budgetCatchUp", { time: ausfall.deferredUntil ? formatiereDatumZeit(ausfall.deferredUntil) : "—" }));
|
||||
}
|
||||
zeilen.push(erstelleElement("p", { klasse: "text-klein", text: `⚠ ${teile.join(" · ")}` }));
|
||||
}
|
||||
|
||||
const aktionen = erstelleElement("div", {
|
||||
klasse: "gruppe-horizontal",
|
||||
stil: "margin-top: 8px; justify-content: flex-end;"
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
// ClawdDotNet — Team overview (#711)
|
||||
//
|
||||
// Who is working, who reached whom in the last seconds, and where something is stuck — at a
|
||||
// glance, like looking across an office. Stage 0: the agents of the active instance and the
|
||||
// live links between them. With units (#683) they become collapsible containers.
|
||||
//
|
||||
// Three rules from the concept (Organisation-Konzept, 10a):
|
||||
// - The layout comes from the structure, not from the hand: no free node editor.
|
||||
// - The state is decided by the instance (code), the page only draws it.
|
||||
// - A click opens a side panel, not the chat — most problems are solved by an approval.
|
||||
|
||||
import { api, holeAktiveInstanz } from "../modules/api.js";
|
||||
import { erstelleElement, erstellePlakette, formatiereDatumZeit } from "../modules/dom.js";
|
||||
import { abonnieren } from "../modules/events.js";
|
||||
import { t } from "../modules/i18n.js";
|
||||
|
||||
// Loaded on demand: pages without a graph do not pay for ~2 MB of script.
|
||||
const LIBRARIES = [
|
||||
"vendor/cytoscape/cytoscape.min.js",
|
||||
"vendor/elkjs/elk.bundled.js",
|
||||
"vendor/cytoscape-elk/cytoscape-elk.js"
|
||||
];
|
||||
|
||||
/** How long a link stays visible after it was observed; it fades out over this time. */
|
||||
const LINK_VISIBLE_MS = 30_000;
|
||||
|
||||
/** How often the picture is refreshed without an event — for "stuck" and approvals. */
|
||||
const REFRESH_MS = 30_000;
|
||||
|
||||
// Every state has a colour AND a symbol: colour alone is not enough.
|
||||
const STATES = {
|
||||
Idle: { symbol: "○", tone: "neutral", colour: "--gedaempft" },
|
||||
Working: { symbol: "▶", tone: "erfolg", colour: "--accent" },
|
||||
Stuck: { symbol: "⧗", tone: "gefahr", colour: "--gefahr" },
|
||||
WaitingForApproval: { symbol: "✋", tone: "warnung", colour: "--warnung" },
|
||||
Problem: { symbol: "⚠", tone: "gefahr", colour: "--gefahr" },
|
||||
BudgetExhausted: { symbol: "$", tone: "warnung", colour: "--warnung" }
|
||||
};
|
||||
|
||||
let librariesLoaded = null;
|
||||
let cy = null;
|
||||
let snapshot = null;
|
||||
let selectedAgent = null;
|
||||
let panel = null;
|
||||
let summary = null;
|
||||
let unsubscribe = null;
|
||||
let refreshTimer = null;
|
||||
let fadeTimer = null;
|
||||
let refreshPending = null;
|
||||
let resizeObserver = null;
|
||||
|
||||
/** Width the last layout was computed for — 0 means "not yet visible", see observeSize. */
|
||||
let layoutWidth = 0;
|
||||
|
||||
// ─── Loading ───
|
||||
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.onload = resolve;
|
||||
script.onerror = () => reject(new Error(`Could not load ${src}`));
|
||||
document.head.append(script);
|
||||
});
|
||||
}
|
||||
|
||||
function loadLibraries() {
|
||||
librariesLoaded ??= (async () => {
|
||||
// In order: cytoscape-elk registers against the globals of the two before it.
|
||||
for (const src of LIBRARIES) await loadScript(src);
|
||||
window.cytoscape.use(window.cytoscapeElk);
|
||||
})();
|
||||
return librariesLoaded;
|
||||
}
|
||||
|
||||
function token(name) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || "#888";
|
||||
}
|
||||
|
||||
// ─── View ───
|
||||
|
||||
export async function renderTeam() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
leaveTeam();
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "team-ansicht" });
|
||||
layout.append(
|
||||
erstelleElement("h1", { text: t("team.title") }),
|
||||
erstelleElement("p", { klasse: "gedaempft", text: t("team.subtitle") })
|
||||
);
|
||||
|
||||
summary = erstelleElement("div", { klasse: "team-zusammenfassung gruppe-horizontal" });
|
||||
const canvas = erstelleElement("div", { klasse: "team-graph", id: "team-graph" });
|
||||
panel = erstelleElement("aside", { klasse: "team-seitenleiste" });
|
||||
panel.hidden = true;
|
||||
|
||||
layout.append(summary, erstelleElement("div", { klasse: "team-flaeche" }, canvas, panel), buildLegend());
|
||||
container.replaceChildren(layout);
|
||||
|
||||
if (!holeAktiveInstanz()) {
|
||||
canvas.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("team.noInstance") }));
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
|
||||
|
||||
try {
|
||||
await loadLibraries();
|
||||
snapshot = await api("/team");
|
||||
} catch (err) {
|
||||
canvas.replaceChildren(erstelleElement("div", { klasse: "fehler-zentriert", text: err.message }));
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.replaceChildren();
|
||||
drawGraph(canvas);
|
||||
|
||||
unsubscribe = abonnieren("activity", onActivity);
|
||||
refreshTimer = setInterval(refresh, REFRESH_MS);
|
||||
fadeTimer = setInterval(fadeLinks, 1_000);
|
||||
}
|
||||
|
||||
/** Called when the route changes: stop listening, stop timers, free the graph. */
|
||||
export function leaveTeam() {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
clearInterval(refreshTimer);
|
||||
clearInterval(fadeTimer);
|
||||
clearTimeout(refreshPending);
|
||||
refreshTimer = fadeTimer = refreshPending = null;
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
cy?.destroy();
|
||||
cy = null;
|
||||
selectedAgent = null;
|
||||
}
|
||||
|
||||
// ─── Graph ───
|
||||
|
||||
function drawGraph(canvas) {
|
||||
cy = window.cytoscape({
|
||||
container: canvas,
|
||||
elements: [],
|
||||
wheelSensitivity: 0.2,
|
||||
minZoom: 0.3,
|
||||
maxZoom: 2.5,
|
||||
// Nodes are not dragged: the layout comes from the structure, not the hand.
|
||||
autoungrabify: true,
|
||||
style: [
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
shape: "rectangle",
|
||||
width: 190,
|
||||
height: 58,
|
||||
"background-color": token("--surface"),
|
||||
"border-width": 2,
|
||||
"border-color": "data(colour)",
|
||||
label: "data(label)",
|
||||
"text-wrap": "wrap",
|
||||
"text-max-width": 176,
|
||||
"text-valign": "center",
|
||||
"text-halign": "center",
|
||||
"font-family": "Barlow, sans-serif",
|
||||
"font-size": 13,
|
||||
color: token("--text")
|
||||
}
|
||||
},
|
||||
{ selector: "node.aktiv", style: { "border-width": 4 } },
|
||||
{ selector: "node:selected", style: { "overlay-color": token("--accent"), "overlay-opacity": 0.15 } },
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: 3,
|
||||
"line-color": token("--accent"),
|
||||
"target-arrow-color": token("--accent"),
|
||||
"target-arrow-shape": "triangle",
|
||||
"curve-style": "bezier",
|
||||
opacity: "data(opacity)"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
cy.on("tap", "node", (event) => openPanel(event.target.id()));
|
||||
cy.on("tap", (event) => { if (event.target === cy) closePanel(); });
|
||||
|
||||
applySnapshot(true);
|
||||
for (const link of snapshot.links ?? []) addLink(link);
|
||||
observeSize(canvas);
|
||||
}
|
||||
|
||||
/**
|
||||
* The area can still be 0 px wide when the graph is first drawn — the page is being laid out
|
||||
* around it. ELK then packs everything into one column, and nothing can be fitted. So: lay
|
||||
* out again once the area has a width, and fit again whenever its size changes (the side
|
||||
* panel opening, the window resizing).
|
||||
*/
|
||||
function observeSize(canvas) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (!cy) return;
|
||||
cy.resize();
|
||||
if (layoutWidth === 0 && canvas.clientWidth > 0) runLayout();
|
||||
else cy.fit(undefined, 30);
|
||||
});
|
||||
resizeObserver.observe(canvas);
|
||||
}
|
||||
|
||||
function runLayout() {
|
||||
const area = cy.container();
|
||||
layoutWidth = area.clientWidth;
|
||||
const layout = cy.layout({
|
||||
name: "elk",
|
||||
fit: false,
|
||||
// Stage 0 has no structure yet, so the agents are packed evenly into the shape of
|
||||
// the area. rectpacking keeps their order, so a box does not jump around when a
|
||||
// state changes. With units (#683) this becomes a layered layout of nested containers.
|
||||
elk: {
|
||||
algorithm: "rectpacking",
|
||||
"elk.spacing.nodeNode": 40,
|
||||
"elk.aspectRatio": area.clientWidth > 0 && area.clientHeight > 0
|
||||
? Math.max(0.5, area.clientWidth / area.clientHeight)
|
||||
: 2
|
||||
}
|
||||
});
|
||||
// ELK computes asynchronously; fit once the positions are there.
|
||||
layout.one("layoutstop", () => cy?.fit(undefined, 30));
|
||||
layout.run();
|
||||
}
|
||||
|
||||
function nodeLabel(member) {
|
||||
const state = STATES[member.state] ?? STATES.Idle;
|
||||
const runs = member.runs.length > 1 ? ` ×${member.runs.length}` : "";
|
||||
return `${state.symbol} ${member.displayName || member.agentId}${runs}\n${t(`team.state.${member.state}`)}`;
|
||||
}
|
||||
|
||||
function applySnapshot(relayout) {
|
||||
if (!cy || !snapshot) return;
|
||||
|
||||
const present = new Set();
|
||||
for (const member of snapshot.members) {
|
||||
present.add(member.agentId);
|
||||
const state = STATES[member.state] ?? STATES.Idle;
|
||||
const data = { id: member.agentId, label: nodeLabel(member), colour: token(state.colour) };
|
||||
const node = cy.getElementById(member.agentId);
|
||||
if (node.nonempty()) {
|
||||
node.data(data);
|
||||
} else {
|
||||
cy.add({ group: "nodes", data });
|
||||
relayout = true;
|
||||
}
|
||||
cy.getElementById(member.agentId).toggleClass("aktiv", member.runs.length > 0);
|
||||
}
|
||||
|
||||
cy.nodes().filter((n) => !present.has(n.id())).remove();
|
||||
|
||||
if (relayout) runLayout();
|
||||
|
||||
renderSummary();
|
||||
if (selectedAgent) openPanel(selectedAgent);
|
||||
}
|
||||
|
||||
// ─── Live ───
|
||||
|
||||
function forActiveInstance(data) {
|
||||
// The manager merges the streams of all instances and stamps each event.
|
||||
return !data?.instanz || data.instanz === holeAktiveInstanz();
|
||||
}
|
||||
|
||||
function onActivity(data) {
|
||||
if (!cy || !forActiveInstance(data)) return;
|
||||
|
||||
if (data.kind === "link" && data.link) addLink(data.link);
|
||||
|
||||
// Start and end change states; ask the instance instead of guessing here.
|
||||
clearTimeout(refreshPending);
|
||||
refreshPending = setTimeout(refresh, 400);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!cy) return;
|
||||
try {
|
||||
snapshot = await api("/team");
|
||||
applySnapshot(false);
|
||||
} catch {
|
||||
// The next event or tick tries again; a failed refresh keeps the last picture.
|
||||
}
|
||||
}
|
||||
|
||||
function addLink(link) {
|
||||
if (!cy) return;
|
||||
const age = Date.now() - new Date(link.atUtc).getTime();
|
||||
if (age > LINK_VISIBLE_MS) return;
|
||||
if (cy.getElementById(link.from).empty() || cy.getElementById(link.to).empty()) return;
|
||||
|
||||
// One edge per pair; a new contact renews it.
|
||||
const id = `link:${link.from}->${link.to}`;
|
||||
const existing = cy.getElementById(id);
|
||||
const data = { id, source: link.from, target: link.to, seenAt: new Date(link.atUtc).getTime(), opacity: 1 };
|
||||
if (existing.nonempty()) existing.data(data);
|
||||
else cy.add({ group: "edges", data });
|
||||
}
|
||||
|
||||
function fadeLinks() {
|
||||
if (!cy) return;
|
||||
const now = Date.now();
|
||||
cy.edges().forEach((edge) => {
|
||||
const age = now - edge.data("seenAt");
|
||||
if (age > LINK_VISIBLE_MS) edge.remove();
|
||||
else edge.data("opacity", Math.max(0.15, 1 - age / LINK_VISIBLE_MS));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Summary and legend ───
|
||||
|
||||
function renderSummary() {
|
||||
if (!summary || !snapshot) return;
|
||||
const counts = {};
|
||||
for (const member of snapshot.members) counts[member.state] = (counts[member.state] ?? 0) + 1;
|
||||
|
||||
const parts = Object.keys(STATES)
|
||||
.filter((state) => counts[state])
|
||||
.map((state) => erstellePlakette(`${STATES[state].symbol} ${counts[state]} ${t(`team.state.${state}`)}`, STATES[state].tone));
|
||||
|
||||
summary.replaceChildren(
|
||||
...(parts.length ? parts : [erstelleElement("span", { klasse: "gedaempft", text: t("team.empty") })])
|
||||
);
|
||||
}
|
||||
|
||||
function buildLegend() {
|
||||
return erstelleElement(
|
||||
"div",
|
||||
{ klasse: "team-legende gruppe-horizontal gedaempft text-klein" },
|
||||
...Object.entries(STATES).map(([state, s]) =>
|
||||
erstelleElement("span", { text: `${s.symbol} ${t(`team.state.${state}`)}` })),
|
||||
erstelleElement("span", { text: `→ ${t("team.legendLink")}` })
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Side panel ───
|
||||
|
||||
function openPanel(agentId) {
|
||||
const member = snapshot?.members.find((m) => m.agentId === agentId);
|
||||
if (!member || !panel) return;
|
||||
selectedAgent = agentId;
|
||||
cy?.getElementById(agentId).select();
|
||||
|
||||
const state = STATES[member.state] ?? STATES.Idle;
|
||||
const rows = [
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
|
||||
erstelleElement("h2", { text: member.displayName || member.agentId }),
|
||||
erstelleElement("button", {
|
||||
klasse: "schaltflaeche flach klein",
|
||||
text: "×",
|
||||
title: t("common.close"),
|
||||
onclick: closePanel
|
||||
})),
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: `${member.agentId} · ${member.model}` }),
|
||||
erstellePlakette(`${state.symbol} ${t(`team.state.${member.state}`)}`, state.tone)
|
||||
];
|
||||
|
||||
// Top layer: what is happening now.
|
||||
if (member.runs.length) {
|
||||
rows.push(erstelleElement("h3", { text: t("team.running") }));
|
||||
for (const run of member.runs) {
|
||||
const parts = [
|
||||
t("team.since", { time: formatiereDatumZeit(run.startedAtUtc) }),
|
||||
run.source,
|
||||
run.taskId ? t("team.task", { id: run.taskId }) : null,
|
||||
run.calledBy ? t("team.calledBy", { agent: run.calledBy }) : null
|
||||
].filter(Boolean);
|
||||
rows.push(erstelleElement("p", { klasse: "text-klein", text: parts.join(" · ") }));
|
||||
}
|
||||
if (member.state === "Stuck" && member.typicalRunMs > 0) {
|
||||
rows.push(erstelleElement("p", {
|
||||
klasse: "text-klein",
|
||||
text: `⚠ ${t("team.stuckHint", { minutes: Math.max(1, Math.round(member.typicalRunMs / 60_000)) })}`
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (member.pendingApprovals > 0) {
|
||||
rows.push(erstelleElement("p", { klasse: "text-klein", text: `✋ ${t("team.approvals", { n: member.pendingApprovals })}` }));
|
||||
}
|
||||
|
||||
// Middle layer: how the last run ended.
|
||||
rows.push(erstelleElement("h3", { text: t("team.lastRun") }));
|
||||
if (member.lastRunStatus) {
|
||||
rows.push(erstelleElement("p", { klasse: "text-klein" },
|
||||
erstellePlakette(member.lastRunStatus, member.lastRunStatus === "Completed" ? "erfolg" : "warnung"),
|
||||
` ${formatiereDatumZeit(member.lastRunAtUtc)}`));
|
||||
if (member.lastRunResult) {
|
||||
rows.push(erstelleElement("p", { klasse: "text-klein team-ergebnis", text: member.lastRunResult }));
|
||||
}
|
||||
} else {
|
||||
rows.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: "—" }));
|
||||
}
|
||||
|
||||
// The actions — the approval first, it is what most problems need.
|
||||
const actions = erstelleElement("div", { klasse: "gruppe-vertikal team-aktionen" });
|
||||
if (member.pendingApprovals > 0) {
|
||||
actions.append(link(`#/staging`, t("team.openApprovals"), "primaer"));
|
||||
}
|
||||
actions.append(
|
||||
link(`#/chat/${encodeURIComponent(member.agentId)}`, t("team.openChat")),
|
||||
// Bottom layer: every run with its raw transcript.
|
||||
link(`#/verbrauch/runs?agent=${encodeURIComponent(member.agentId)}`, t("team.openRuns"))
|
||||
);
|
||||
rows.push(actions);
|
||||
|
||||
panel.replaceChildren(...rows);
|
||||
panel.hidden = false;
|
||||
}
|
||||
|
||||
function link(href, text, style = "flach") {
|
||||
return erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: `${style} klein`,
|
||||
text,
|
||||
onclick: () => { window.location.hash = href; }
|
||||
});
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
selectedAgent = null;
|
||||
cy?.elements().unselect();
|
||||
if (panel) panel.hidden = true;
|
||||
}
|
||||
@@ -210,7 +210,9 @@ public sealed class TaskboardTool : IAgentTool
|
||||
private static async Task<List<(TaskItem Task, TaskTitleMatch Match)>> SucheAehnlicheAsync(
|
||||
TaskboardService board, string title, string? exceptId, CancellationToken ct)
|
||||
{
|
||||
var alle = await board.ListAsync(new TaskQuery { Limit = 500 }, ct);
|
||||
// Uncapped (#716): with a limit of 500, a duplicate among older, low-priority tasks
|
||||
// stayed invisible once the board grew beyond that.
|
||||
var alle = await board.ListAllAsync(ct);
|
||||
return TaskSimilarity.FindOpen(alle, title, exceptId);
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,39 @@ public sealed record TaskDto(
|
||||
TaskWhenDto? When = null,
|
||||
DateTime? NextRunUtc = null,
|
||||
TaskLastRunDto? LastRun = null,
|
||||
string? Hinweis = null);
|
||||
string? Hinweis = null,
|
||||
TaskBudgetMissDto? BudgetMiss = null);
|
||||
|
||||
/// <summary>
|
||||
/// #716: Runs of this task the budget refused — only present while something is open
|
||||
/// (misses since the last run that took place, a pending catch-up or a deferral).
|
||||
/// </summary>
|
||||
public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver);
|
||||
|
||||
/// <summary>#711: The team overview — every agent and who reached whom recently.</summary>
|
||||
public sealed record TeamSnapshotDto(IReadOnlyList<TeamMemberDto> Members, IReadOnlyList<AgentLinkDto> Links, DateTime AtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// #711: One agent in the team overview. <see cref="State"/> is one of Idle, Working, Stuck,
|
||||
/// WaitingForApproval, Problem, BudgetExhausted — decided by the instance, not the page.
|
||||
/// </summary>
|
||||
public sealed record TeamMemberDto(
|
||||
string AgentId,
|
||||
string DisplayName,
|
||||
string Model,
|
||||
string State,
|
||||
IReadOnlyList<ActiveRunDto> Runs,
|
||||
int PendingApprovals,
|
||||
string? LastRunStatus,
|
||||
DateTime? LastRunAtUtc,
|
||||
string? LastRunResult,
|
||||
long TypicalRunMs);
|
||||
|
||||
/// <summary>#711: A run in progress.</summary>
|
||||
public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc);
|
||||
|
||||
/// <summary>#711: One agent reaching another.</summary>
|
||||
public sealed record AgentLinkDto(string From, string To, string Kind, DateTime AtUtc);
|
||||
|
||||
/// <summary>Der letzte Lauf einer Aufgabe (#594) - aus dem Beleg, samt Kosten.</summary>
|
||||
public sealed record TaskLastRunDto(DateTime OccurredAt, string Status, decimal CostUsd, bool CostIsKnown, string ResultRef);
|
||||
|
||||
@@ -69,6 +69,21 @@ internal static class Mapping
|
||||
public static ChatEntryDto ToDto(this ChatEntry entry)
|
||||
=> new(entry.Role, entry.Content, entry.Timestamp, entry.Source);
|
||||
|
||||
public static TeamSnapshotDto ToDto(this App.Services.TeamSnapshot snapshot)
|
||||
=> new(
|
||||
snapshot.Members.Select(m => new TeamMemberDto(
|
||||
m.AgentId, m.DisplayName, m.Model, m.State.ToString(),
|
||||
m.Runs.Select(ToDto).ToList(),
|
||||
m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs)).ToList(),
|
||||
snapshot.RecentLinks.Select(ToDto).ToList(),
|
||||
snapshot.AtUtc);
|
||||
|
||||
public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run)
|
||||
=> new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);
|
||||
|
||||
public static AgentLinkDto ToDto(this Core.Activity.AgentLink link)
|
||||
=> new(link.From, link.To, link.Kind, link.AtUtc);
|
||||
|
||||
public static TaskDto ToDto(this TaskOverview overview)
|
||||
=> overview.Task.ToDto() with
|
||||
{
|
||||
@@ -129,6 +144,10 @@ internal static class Mapping
|
||||
task.UpdatedAt,
|
||||
task.When is { } when
|
||||
? new TaskWhenDto(when.Kind.ToString().ToLowerInvariant(), when.Value, when.TimeZone)
|
||||
: null,
|
||||
BudgetMiss: task.BudgetMissCount > 0 || task.CatchUpPending || task.DeferredUntil is not null
|
||||
? new TaskBudgetMissDto(task.BudgetMissCount, task.LastBudgetMissAt, task.DeferredUntil,
|
||||
task.CatchUpPending, task.IsCarriedOver)
|
||||
: null);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -31,6 +31,7 @@ internal static class ApiEndpoints
|
||||
api.MapUsage();
|
||||
api.MapAnalytics();
|
||||
api.MapLogs();
|
||||
api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
|
||||
|
||||
app.MapEvents();
|
||||
app.MapControl();
|
||||
|
||||
@@ -71,12 +71,23 @@ internal static class EventEndpoints
|
||||
void OnRunCompleted(string model, Core.Engine.AgentRunResult result)
|
||||
=> Publish("run", new
|
||||
{
|
||||
agentId = result.AgentId,
|
||||
model,
|
||||
status = result.Status.ToString(),
|
||||
steps = result.StepCount,
|
||||
tokens = result.TokensUsed
|
||||
});
|
||||
|
||||
// #711: the team overview — who starts, who stops, who reached whom.
|
||||
void OnRunStarted(Core.Activity.ActiveRun run)
|
||||
=> Publish("activity", new { kind = "started", run = run.ToDto() });
|
||||
|
||||
void OnRunFinished(Core.Activity.ActiveRun run, string status)
|
||||
=> Publish("activity", new { kind = "finished", run = run.ToDto(), status });
|
||||
|
||||
void OnLink(Core.Activity.AgentLink link)
|
||||
=> Publish("activity", new { kind = "link", link = link.ToDto() });
|
||||
|
||||
void OnInstanceChanged(InstanceChange change)
|
||||
=> Publish("instance", new { kind = change.Kind.ToString(), id = change.Id });
|
||||
|
||||
@@ -88,6 +99,9 @@ internal static class EventEndpoints
|
||||
engine.OnChatEntryAdded += OnChat;
|
||||
engine.OnStepCompleted += OnStep;
|
||||
engine.OnRunCompleted += OnRunCompleted;
|
||||
engine.Activity.RunStarted += OnRunStarted;
|
||||
engine.Activity.RunFinished += OnRunFinished;
|
||||
engine.Activity.LinkObserved += OnLink;
|
||||
}
|
||||
|
||||
host.InstanceChanged += OnInstanceChanged;
|
||||
@@ -106,6 +120,9 @@ internal static class EventEndpoints
|
||||
e.OnChatEntryAdded -= OnChat;
|
||||
e.OnStepCompleted -= OnStep;
|
||||
e.OnRunCompleted -= OnRunCompleted;
|
||||
e.Activity.RunStarted -= OnRunStarted;
|
||||
e.Activity.RunFinished -= OnRunFinished;
|
||||
e.Activity.LinkObserved -= OnLink;
|
||||
}
|
||||
|
||||
host.InstanceChanged -= OnInstanceChanged;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Core.Activity;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.App.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// #711: The rules that decide what the team overview shows for an agent. Every state comes
|
||||
/// from a recorded fact, never from a model's judgement.
|
||||
/// </summary>
|
||||
public sealed class TeamOverviewTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2027, 3, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static ActiveRun RunningFor(TimeSpan duration)
|
||||
=> new("r", "worker", "task", null, null, Now - duration);
|
||||
|
||||
[Fact]
|
||||
public void Nothing_running_and_a_good_last_run_is_idle()
|
||||
=> TeamOverview.Classify([], 0, "Completed", TimeSpan.FromMinutes(1), Now).ShouldBe(TeamState.Idle);
|
||||
|
||||
[Fact]
|
||||
public void An_agent_without_any_run_yet_is_idle()
|
||||
=> TeamOverview.Classify([], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
|
||||
|
||||
[Fact]
|
||||
public void A_run_in_progress_is_working()
|
||||
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, null, TimeSpan.FromMinutes(1), Now)
|
||||
.ShouldBe(TeamState.Working);
|
||||
|
||||
[Fact]
|
||||
public void A_run_far_beyond_the_usual_duration_is_stuck()
|
||||
// Usually 2 minutes: three times that is 6, running for 7.
|
||||
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(7))], 0, null, TimeSpan.FromMinutes(2), Now)
|
||||
.ShouldBe(TeamState.Stuck);
|
||||
|
||||
[Fact]
|
||||
public void Short_runs_get_a_floor_before_they_count_as_stuck()
|
||||
// Usually 20 seconds: three times is one minute, but the floor is five.
|
||||
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(3))], 0, null, TimeSpan.FromSeconds(20), Now)
|
||||
.ShouldBe(TeamState.Working);
|
||||
|
||||
[Fact]
|
||||
public void Without_history_a_run_is_stuck_only_after_a_long_while()
|
||||
{
|
||||
TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(10))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Working);
|
||||
TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(16))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Stuck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_open_approval_outranks_working()
|
||||
// What the human can do something about comes first.
|
||||
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 2, null, TimeSpan.FromMinutes(1), Now)
|
||||
.ShouldBe(TeamState.WaitingForApproval);
|
||||
|
||||
[Fact]
|
||||
public void Stuck_outranks_an_open_approval()
|
||||
=> TeamOverview.Classify([RunningFor(TimeSpan.FromHours(1))], 2, null, TimeSpan.FromMinutes(1), Now)
|
||||
.ShouldBe(TeamState.Stuck);
|
||||
|
||||
[Theory]
|
||||
[InlineData("Failed")]
|
||||
[InlineData("LoopLimitExceeded")]
|
||||
[InlineData("TargetBusy")]
|
||||
public void A_failed_last_run_is_a_problem(string status)
|
||||
=> TeamOverview.Classify([], 0, status, TimeSpan.Zero, Now).ShouldBe(TeamState.Problem);
|
||||
|
||||
[Fact]
|
||||
public void A_refused_last_run_shows_the_exhausted_budget()
|
||||
=> TeamOverview.Classify([], 0, "BudgetExceeded", TimeSpan.Zero, Now).ShouldBe(TeamState.BudgetExhausted);
|
||||
|
||||
[Fact]
|
||||
public void A_new_run_hides_the_old_problem()
|
||||
// A new run is the attempt to get past it; the problem shows again if it fails.
|
||||
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, "Failed", TimeSpan.FromMinutes(1), Now)
|
||||
.ShouldBe(TeamState.Working);
|
||||
|
||||
[Fact]
|
||||
public void A_cancelled_run_is_not_a_problem()
|
||||
// Cancelled means a human stopped it — that was a decision, not a fault.
|
||||
=> TeamOverview.Classify([], 0, "Cancelled", TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using ClawdDotNet.Core.Activity;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Activity;
|
||||
|
||||
/// <summary>
|
||||
/// #711: Who is working right now and who reached whom — the live side of the team overview.
|
||||
/// </summary>
|
||||
public sealed class AgentActivityTests
|
||||
{
|
||||
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2027, 3, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
[Fact]
|
||||
public void A_run_is_active_until_its_handle_is_disposed()
|
||||
{
|
||||
var activity = new AgentActivity(_clock);
|
||||
|
||||
using (var run = activity.Begin("r1", "worker", "task", "t-1", null))
|
||||
{
|
||||
var active = activity.ActiveRuns().ShouldHaveSingleItem();
|
||||
active.AgentId.ShouldBe("worker");
|
||||
active.TaskId.ShouldBe("t-1");
|
||||
active.CalledBy.ShouldBeNull();
|
||||
run.Complete("Completed");
|
||||
}
|
||||
|
||||
activity.ActiveRuns().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_and_end_are_announced_with_the_status()
|
||||
{
|
||||
var activity = new AgentActivity(_clock);
|
||||
var log = new List<string>();
|
||||
activity.RunStarted += r => log.Add($"start {r.AgentId}");
|
||||
activity.RunFinished += (r, status) => log.Add($"end {r.AgentId} {status}");
|
||||
|
||||
using (var run = activity.Begin("r1", "worker", "chat", null, null))
|
||||
run.Complete("LoopLimitExceeded");
|
||||
using (activity.Begin("r2", "worker", "chat", null, null)) { }
|
||||
|
||||
log.ShouldBe(["start worker", "end worker LoopLimitExceeded", "start worker", "end worker Aborted"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_delegated_run_records_who_called_whom()
|
||||
{
|
||||
var activity = new AgentActivity(_clock);
|
||||
AgentLink? seen = null;
|
||||
activity.LinkObserved += l => seen = l;
|
||||
var chain = DelegationChain.Root("lead").Append("buyer");
|
||||
|
||||
using (activity.Begin("r1", "buyer", "agent_comm", null, chain)) { }
|
||||
|
||||
seen.ShouldNotBeNull();
|
||||
seen.From.ShouldBe("lead");
|
||||
seen.To.ShouldBe("buyer");
|
||||
seen.Kind.ShouldBe("delegation");
|
||||
activity.RecentLinks(TimeSpan.FromMinutes(1)).ShouldHaveSingleItem().From.ShouldBe("lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Old_links_fall_out_of_the_recent_window()
|
||||
{
|
||||
var activity = new AgentActivity(_clock);
|
||||
using (activity.Begin("r1", "buyer", "agent_comm", null, DelegationChain.Root("lead").Append("buyer"))) { }
|
||||
|
||||
_clock.Advance(TimeSpan.FromMinutes(11));
|
||||
|
||||
activity.RecentLinks(TimeSpan.FromMinutes(10)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_memory_of_links_is_bounded()
|
||||
{
|
||||
var activity = new AgentActivity(_clock);
|
||||
var chain = DelegationChain.Root("lead").Append("buyer");
|
||||
|
||||
for (var i = 0; i < AgentActivity.RecentLinkCapacity + 50; i++)
|
||||
using (activity.Begin($"r{i}", "buyer", "agent_comm", null, chain)) { }
|
||||
|
||||
activity.RecentLinks(TimeSpan.FromDays(1)).Count.ShouldBe(AgentActivity.RecentLinkCapacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_subscriber_that_throws_does_not_break_the_run()
|
||||
{
|
||||
var activity = new AgentActivity(_clock);
|
||||
activity.RunStarted += _ => throw new InvalidOperationException("broken page");
|
||||
activity.RunFinished += (_, _) => throw new InvalidOperationException("broken page");
|
||||
|
||||
Should.NotThrow(() =>
|
||||
{
|
||||
using var run = activity.Begin("r1", "worker", "chat", null, null);
|
||||
run.Complete("Completed");
|
||||
});
|
||||
activity.ActiveRuns().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ─── through the engine ───
|
||||
|
||||
[Fact]
|
||||
public async Task The_engine_announces_a_delegated_run_with_its_caller()
|
||||
{
|
||||
var fixture = new EngineFixture();
|
||||
fixture.AddAgent("lead");
|
||||
fixture.AddAgent("buyer");
|
||||
fixture.Client.RespondsWithText("Erledigt.");
|
||||
|
||||
var started = new List<ActiveRun>();
|
||||
var finished = new List<string>();
|
||||
fixture.Engine.Activity.RunStarted += started.Add;
|
||||
fixture.Engine.Activity.RunFinished += (_, status) => finished.Add(status);
|
||||
|
||||
var result = await fixture.Engine.SendMessageAsync("lead", "buyer", "Bitte pruefen", "inst", null, default);
|
||||
|
||||
result.Delivered.ShouldBeTrue(result.Error);
|
||||
var run = started.ShouldHaveSingleItem();
|
||||
run.AgentId.ShouldBe("buyer");
|
||||
run.CalledBy.ShouldBe("lead");
|
||||
finished.ShouldBe(["Completed"]);
|
||||
fixture.Engine.Activity.ActiveRuns().ShouldBeEmpty();
|
||||
fixture.Engine.Activity.RecentLinks(TimeSpan.FromMinutes(1)).ShouldHaveSingleItem().To.ShouldBe("buyer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fresh_run_is_visible_too()
|
||||
{
|
||||
// Before #711 only runs through ChatAsync counted as "running"; RunAsync (@new) did not.
|
||||
var fixture = new EngineFixture();
|
||||
var agent = fixture.AddAgent("worker");
|
||||
fixture.Client.RespondsWithText("Fertig.");
|
||||
|
||||
var started = new List<ActiveRun>();
|
||||
fixture.Engine.Activity.RunStarted += started.Add;
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
|
||||
started.ShouldHaveSingleItem().AgentId.ShouldBe("worker");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
using ClawdDotNet.Core.Budget;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Budget;
|
||||
|
||||
/// <summary>
|
||||
/// #716, stage 3: late work — carried-over tasks and catch-ups — only uses budget the rest
|
||||
/// of the day does not need. The new day has right of way.
|
||||
/// </summary>
|
||||
public sealed class BudgetHeadroomTests
|
||||
{
|
||||
// 2027-03-10 14:00 UTC; the test clock's local zone is UTC, so the day ends at 24:00 UTC.
|
||||
private static readonly DateTime Now = new(2027, 3, 10, 14, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime CreatedEarlier = new(2027, 3, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static AgentConfig Agent(decimal dailyCost = 1.00m)
|
||||
=> new() { AgentId = "worker", Budget = new AgentBudget { DailyCostUsd = dailyCost } };
|
||||
|
||||
/// <summary>Runs averaging 0.05 USD.</summary>
|
||||
private static List<RunUsage> History(decimal cost = 0.05m)
|
||||
=> [.. Enumerable.Range(0, 5).Select(_ => new RunUsage { AgentId = "worker", PromptTokens = 1_000, CostUsd = cost, CostIsKnown = true })];
|
||||
|
||||
private static TaskItem Cron(string id, string expression, string? lastOccurrence = null) => new()
|
||||
{
|
||||
Id = id, Title = id, Assignee = "@worker", CreatedAt = CreatedEarlier, LastOccurrence = lastOccurrence,
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = expression, TimeZone = "UTC" }
|
||||
};
|
||||
|
||||
private static TaskItem Carried(string id) => new()
|
||||
{
|
||||
Id = id, Title = id, Assignee = "@worker", CreatedAt = CreatedEarlier, BudgetMissCount = 1
|
||||
};
|
||||
|
||||
private static BudgetHeadroom Headroom(decimal used, AgentConfig agent, InstanceBudget? instance = null, decimal perRun = 0.05m)
|
||||
=> new(new BudgetGuard(new FakeUsage(used, History(perRun))),
|
||||
() => [agent], () => instance ?? InstanceBudget.Unlimited, new UtcClock(Now));
|
||||
|
||||
// ═══════════════════ counting the rest of the day ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public void Hourly_from_14_30_until_midnight_is_nine_more_runs()
|
||||
{
|
||||
// 15:00 … 23:00. The 14:00 run is done (marker), so nothing is due right now.
|
||||
var task = Cron("t", "0 * * * *", lastOccurrence: "2027-03-10T14:00:00.0000000Z");
|
||||
|
||||
TaskSchedule.OccurrencesBetween(task, Now.AddMinutes(30), Now.Date.AddDays(1)).ShouldBe(9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_run_due_right_now_counts_as_well()
|
||||
{
|
||||
// Last run 13:00, so the 14:00 run is due now; then 15:00 … 23:00.
|
||||
var task = Cron("t", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
|
||||
|
||||
TaskSchedule.OccurrencesBetween(task, Now.AddMinutes(1), Now.Date.AddDays(1)).ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_one_time_task_counts_once_until_it_ran()
|
||||
{
|
||||
var open = new TaskItem { Id = "t", CreatedAt = CreatedEarlier };
|
||||
var done = open with { LastOccurrence = "2027-03-10T09:00:00.0000000Z" };
|
||||
|
||||
TaskSchedule.OccurrencesBetween(open, Now, Now.AddHours(1)).ShouldBe(1);
|
||||
TaskSchedule.OccurrencesBetween(done, Now, Now.AddHours(1)).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_interval_counts_its_remaining_ticks()
|
||||
{
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t", CreatedAt = CreatedEarlier, LastOccurrence = "2027-03-10T13:45:00.0000000Z",
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m", TimeZone = "UTC" }
|
||||
};
|
||||
|
||||
// 14:15, 14:45, 15:15, 15:45 before 16:00.
|
||||
TaskSchedule.OccurrencesBetween(task, Now, Now.AddHours(2)).ShouldBe(4);
|
||||
}
|
||||
|
||||
// ═══════════════════ headroom ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Late_work_fits_when_the_rest_of_the_day_leaves_room()
|
||||
{
|
||||
// Limit 1.00, used 0.30, margin 0.10. Rest of the day: 10 hourly runs à 0.05 = 0.50.
|
||||
// Left: 1.00 - 0.30 - 0.50 - 0.10 = 0.10 ≥ 0.05 for the late run.
|
||||
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
|
||||
var late = Carried("t-late");
|
||||
|
||||
var room = await Headroom(used: 0.30m, Agent()).HasRoomAsync(late, [hourly, late], Now, default);
|
||||
|
||||
room.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Late_work_waits_when_the_rest_of_the_day_needs_the_budget()
|
||||
{
|
||||
// Same, but 0.40 used: 1.00 - 0.40 - 0.50 - 0.10 = 0.00 < 0.05.
|
||||
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
|
||||
var late = Carried("t-late");
|
||||
|
||||
var room = await Headroom(used: 0.40m, Agent()).HasRoomAsync(late, [hourly, late], Now, default);
|
||||
|
||||
room.ShouldBeFalse("the day's own schedule has right of way");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Other_late_work_does_not_count_as_the_days_demand()
|
||||
{
|
||||
// Ten carried-over tasks do not reserve budget against each other — they are what
|
||||
// waits for headroom, not what headroom is kept for.
|
||||
var late = Carried("t-late");
|
||||
var others = Enumerable.Range(0, 10).Select(i => Carried($"t-other-{i}")).ToList();
|
||||
|
||||
var room = await Headroom(used: 0.50m, Agent()).HasRoomAsync(late, [late, .. others], Now, default);
|
||||
|
||||
room.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_instance_limit_counts_the_schedules_of_all_agents()
|
||||
{
|
||||
var agent = Agent(dailyCost: 0); // no limit of its own
|
||||
var instance = new InstanceBudget { DailyCostUsd = 1.00m };
|
||||
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
|
||||
var late = Carried("t-late");
|
||||
|
||||
(await Headroom(used: 0.30m, agent, instance).HasRoomAsync(late, [hourly, late], Now, default)).ShouldBeTrue();
|
||||
(await Headroom(used: 0.40m, agent, instance).HasRoomAsync(late, [hourly, late], Now, default)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_any_limit_there_is_always_room()
|
||||
{
|
||||
var late = Carried("t-late");
|
||||
|
||||
(await Headroom(used: 99m, Agent(dailyCost: 0)).HasRoomAsync(late, [late], Now, default)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ═══════════════════ the day ahead ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task The_outlook_names_the_hour_the_budget_runs_out()
|
||||
{
|
||||
// Limit 1.00, used 0.60, runs at 14:00 (due now), 15:00 … 23:00 à 0.05. Before the
|
||||
// 22:00 run 0.60 + 8 × 0.05 = 1.00 is used — from there on everything is refused.
|
||||
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
|
||||
|
||||
var shortfalls = await Headroom(used: 0.60m, Agent()).ShortfallsAsync([hourly], Now, default);
|
||||
|
||||
var shortfall = shortfalls.ShouldHaveSingleItem();
|
||||
shortfall.AgentId.ShouldBe("worker");
|
||||
shortfall.ExhaustsAtUtc.ShouldBe(new DateTime(2027, 3, 10, 22, 0, 0, DateTimeKind.Utc));
|
||||
shortfall.RunsRefused.ShouldBe(2);
|
||||
shortfall.RunsPlanned.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_day_that_fits_has_no_shortfall()
|
||||
{
|
||||
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
|
||||
|
||||
(await Headroom(used: 0.30m, Agent()).ShortfallsAsync([hourly], Now, default)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Late_work_does_not_make_the_day_fall_short()
|
||||
{
|
||||
// Fifty carried-over tasks only ever run in headroom — they cannot be the reason the
|
||||
// day's own runs are refused.
|
||||
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
|
||||
var late = Enumerable.Range(0, 50).Select(i => Carried($"t-late-{i}")).ToList();
|
||||
|
||||
(await Headroom(used: 0.30m, Agent()).ShortfallsAsync([hourly, .. late], Now, default)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_instance_limit_gets_its_own_forecast()
|
||||
{
|
||||
var hourly = Cron("t-hourly", "0 * * * *", lastOccurrence: "2027-03-10T13:00:00.0000000Z");
|
||||
var instance = new InstanceBudget { DailyCostUsd = 1.00m };
|
||||
|
||||
var shortfalls = await Headroom(used: 0.60m, Agent(dailyCost: 0), instance).ShortfallsAsync([hourly], Now, default);
|
||||
|
||||
shortfalls.ShouldHaveSingleItem().AgentId.ShouldBe("", "empty means: the instance");
|
||||
}
|
||||
|
||||
// ═══════════════════ test doubles ═══════════════════
|
||||
|
||||
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,173 @@
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
using ClawdDotNet.Core.Budget;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Budget;
|
||||
|
||||
/// <summary>
|
||||
/// #716 (the rest of #682): Runs in flight count against the budget with their estimate.
|
||||
///
|
||||
/// Before, every run was checked on its own against what was already recorded, so runs
|
||||
/// starting together all saw the same figure and all passed. #682 capped how many could
|
||||
/// start at once; the reservation closes the gap.
|
||||
/// </summary>
|
||||
public sealed class BudgetReservationTests
|
||||
{
|
||||
private static AgentConfig Agent(decimal dailyCost = 1.00m, long dailyTokens = 0)
|
||||
=> new() { AgentId = "worker", Budget = new AgentBudget { DailyCostUsd = dailyCost, DailyTokens = dailyTokens } };
|
||||
|
||||
/// <summary>Recent runs averaging 0.30 USD and 3,000 tokens.</summary>
|
||||
private static List<RunUsage> History(decimal cost = 0.30m, int tokens = 3_000)
|
||||
=> [.. Enumerable.Range(0, 5).Select(_ => new RunUsage
|
||||
{
|
||||
AgentId = "worker", PromptTokens = tokens, CostUsd = cost, CostIsKnown = true, Status = "Completed"
|
||||
})];
|
||||
|
||||
[Fact]
|
||||
public async Task Runs_in_flight_count_against_the_budget()
|
||||
{
|
||||
var guard = new BudgetGuard(new FakeUsage(usedCost: 0.60m, History()));
|
||||
|
||||
var (first, a) = await guard.ReserveAsync(Agent(), InstanceBudget.Unlimited, default);
|
||||
var (second, b) = await guard.ReserveAsync(Agent(), InstanceBudget.Unlimited, default);
|
||||
var (third, c) = await guard.ReserveAsync(Agent(), InstanceBudget.Unlimited, default);
|
||||
|
||||
first.IsAllowed.ShouldBeTrue("0.60 used, nothing in flight");
|
||||
second.IsAllowed.ShouldBeTrue("0.60 used + 0.30 in flight");
|
||||
third.IsAllowed.ShouldBeFalse("0.60 used + 0.60 in flight — before #716 this one passed too");
|
||||
third.Reason.ShouldNotBeNull().ShouldContain("reserviert");
|
||||
c.ShouldBeNull();
|
||||
|
||||
a!.Dispose();
|
||||
b!.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_finished_run_frees_its_reservation()
|
||||
{
|
||||
var guard = new BudgetGuard(new FakeUsage(usedCost: 0.60m, History()));
|
||||
|
||||
var (_, a) = await guard.ReserveAsync(Agent(), InstanceBudget.Unlimited, default);
|
||||
var (_, b) = await guard.ReserveAsync(Agent(), InstanceBudget.Unlimited, default);
|
||||
a!.Dispose();
|
||||
a.Dispose(); // twice is harmless
|
||||
|
||||
var (again, c) = await guard.ReserveAsync(Agent(), InstanceBudget.Unlimited, default);
|
||||
again.IsAllowed.ShouldBeTrue();
|
||||
|
||||
b!.Dispose();
|
||||
c!.Dispose();
|
||||
guard.Reserved("worker").ShouldBe(default(RunEstimate));
|
||||
guard.Reserved("").ShouldBe(default(RunEstimate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_single_run_is_not_refused_because_of_its_own_estimate()
|
||||
{
|
||||
// 0.95 of 1.00 used, an average run costs 0.30: a small task may still fit, and
|
||||
// refusing it on the average would be guessing.
|
||||
var guard = new BudgetGuard(new FakeUsage(usedCost: 0.95m, History()));
|
||||
|
||||
var (status, reservation) = await guard.ReserveAsync(Agent(), InstanceBudget.Unlimited, default);
|
||||
|
||||
status.IsAllowed.ShouldBeTrue();
|
||||
reservation!.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parallel_starts_cannot_all_pass_together()
|
||||
{
|
||||
var guard = new BudgetGuard(new FakeUsage(usedCost: 0m, History()));
|
||||
|
||||
var results = await Task.WhenAll(Enumerable.Range(0, 10)
|
||||
.Select(_ => guard.ReserveAsync(Agent(), InstanceBudget.Unlimited, default)));
|
||||
|
||||
// 0.00, 0.30, 0.60, 0.90 in flight pass; from 1.20 on the budget is spoken for.
|
||||
results.Count(r => r.Status.IsAllowed).ShouldBe(4);
|
||||
|
||||
foreach (var (_, reservation) in results)
|
||||
reservation?.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_token_limit_counts_reservations_too()
|
||||
{
|
||||
var guard = new BudgetGuard(new FakeUsage(usedCost: 0m, History(cost: 0m, tokens: 4_000)));
|
||||
var agent = Agent(dailyCost: 0, dailyTokens: 10_000);
|
||||
|
||||
var (a, ra) = await guard.ReserveAsync(agent, InstanceBudget.Unlimited, default);
|
||||
var (b, rb) = await guard.ReserveAsync(agent, InstanceBudget.Unlimited, default);
|
||||
var (c, _) = await guard.ReserveAsync(agent, InstanceBudget.Unlimited, default);
|
||||
var (d, _) = await guard.ReserveAsync(agent, InstanceBudget.Unlimited, default);
|
||||
|
||||
a.IsAllowed.ShouldBeTrue();
|
||||
b.IsAllowed.ShouldBeTrue();
|
||||
c.IsAllowed.ShouldBeTrue("8,000 of 10,000 reserved");
|
||||
d.IsAllowed.ShouldBeFalse("12,000 reserved");
|
||||
d.Limit.ShouldBe(BudgetLimit.AgentTokens);
|
||||
|
||||
ra!.Dispose();
|
||||
rb!.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_instance_limit_sees_the_runs_of_all_agents()
|
||||
{
|
||||
var guard = new BudgetGuard(new FakeUsage(usedCost: 0m, History()));
|
||||
var instance = new InstanceBudget { DailyCostUsd = 0.50m };
|
||||
var unlimitedAgent = Agent(dailyCost: 0);
|
||||
|
||||
var (a, _) = await guard.ReserveAsync(unlimitedAgent, instance, default);
|
||||
var (b, _) = await guard.ReserveAsync(unlimitedAgent, instance, default);
|
||||
var (c, _) = await guard.ReserveAsync(unlimitedAgent, instance, default);
|
||||
|
||||
a.IsAllowed.ShouldBeTrue();
|
||||
b.IsAllowed.ShouldBeTrue("0.30 in flight");
|
||||
c.IsAllowed.ShouldBeFalse("0.60 in flight over an instance limit of 0.50");
|
||||
c.Limit.ShouldBe(BudgetLimit.InstanceCost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_any_limit_nothing_is_reserved()
|
||||
{
|
||||
var guard = new BudgetGuard(new FakeUsage(usedCost: 0m, History()));
|
||||
|
||||
var (status, reservation) = await guard.ReserveAsync(Agent(dailyCost: 0), InstanceBudget.Unlimited, default);
|
||||
|
||||
status.IsAllowed.ShouldBeTrue();
|
||||
reservation.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runs_without_usage_do_not_drag_the_estimate_down()
|
||||
{
|
||||
var history = History();
|
||||
history.Add(new RunUsage { AgentId = "worker", Status = "BudgetExceeded" }); // 0 tokens
|
||||
var guard = new BudgetGuard(new FakeUsage(usedCost: 0m, history));
|
||||
|
||||
var estimate = await guard.EstimateRunAsync("worker", default);
|
||||
|
||||
estimate.CostUsd.ShouldBe(0.30m);
|
||||
estimate.Tokens.ShouldBe(3_000);
|
||||
}
|
||||
|
||||
private sealed class FakeUsage(decimal usedCost, IReadOnlyList<RunUsage> recent) : IUsageRepository
|
||||
{
|
||||
public Task RecordAsync(RunUsage usage, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public async Task<DailyUsage> GetDailyAsync(DateOnly date, string agentId, CancellationToken ct)
|
||||
{
|
||||
await Task.Yield(); // like the database: the caller does not get the answer synchronously
|
||||
return 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);
|
||||
}
|
||||
}
|
||||
@@ -276,17 +276,16 @@ public class HarnessHaertungTests
|
||||
("ParallelTool", "{}", "c3"))
|
||||
.RespondsWithText("Fertig.");
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||
sw.Stop();
|
||||
|
||||
// Der belastbare Nachweis: Aufrufe haben sich überlappt.
|
||||
//
|
||||
// Hier stand zusätzlich eine Wanduhr-Prüfung (unter 750 ms, seriell wären es 900).
|
||||
// Sie schlug am 2026-09-24 mit 927 ms fehl, als die Testprojekte parallel liefen —
|
||||
// die Zeit misst die Last der Maschine, nicht das Verhalten der Engine. Der
|
||||
// Höchststand gleichzeitiger Aufrufe beweist dasselbe, ohne zu wackeln.
|
||||
Assert.True(tool.MaxConcurrent > 1,
|
||||
$"Erwartet: überlappende Aufrufe. Höchststand war {tool.MaxConcurrent}.");
|
||||
|
||||
// Die Laufzeit als zweite, schwächere Bestätigung — seriell wären es 900 ms.
|
||||
Assert.True(sw.ElapsedMilliseconds < 750,
|
||||
$"Erwartet: deutlich unter 900 ms. Gemessen: {sw.ElapsedMilliseconds} ms.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// #716: What the scanner does with a run the budget refused.
|
||||
///
|
||||
/// Before, a one-time task was retried every 30 minutes for the rest of the day — each
|
||||
/// attempt another refusal and another receipt — and then ran after midnight at the
|
||||
/// expense of the new day, without anyone having decided that. A recurring task's
|
||||
/// occurrence lapsed silently.
|
||||
/// </summary>
|
||||
public sealed class BudgetMissTests : IDisposable
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly SqliteStorage _storage;
|
||||
private readonly SqliteTaskRepository _repo;
|
||||
|
||||
// 2027: a cron schedule never looks back before the task's creation date.
|
||||
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2027, 3, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
public BudgetMissTests()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
||||
_repo = new SqliteTaskRepository(_storage);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleanup is secondary */ }
|
||||
}
|
||||
|
||||
private TaskScanner Scanner(ITaskDispatcher dispatcher, BudgetMissPolicy policy = BudgetMissPolicy.Expire)
|
||||
=> new(_repo, dispatcher, NullLoggerFactory.Instance, clock: _clock, budgetMissPolicy: _ => policy);
|
||||
|
||||
private Task<TaskItem> SeedOneTime(string id, int priority = 3)
|
||||
=> _repo.UpsertAsync(new TaskItem { Id = id, Title = id, Assignee = "@worker", Priority = priority }, default);
|
||||
|
||||
private Task<TaskItem> SeedHourly(string id)
|
||||
=> _repo.UpsertAsync(new TaskItem
|
||||
{
|
||||
Id = id, Title = id, Assignee = "@worker",
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 * * * *", TimeZone = "UTC" }
|
||||
}, default);
|
||||
|
||||
private async Task<TaskItem> Get(string id) => (await _repo.GetAsync(id, default))!;
|
||||
|
||||
/// <summary>The next local midnight, in UTC — the budget day follows the local date.</summary>
|
||||
private DateTime NextLocalMidnightUtc()
|
||||
{
|
||||
var zone = _clock.LocalTimeZone;
|
||||
var local = TimeZoneInfo.ConvertTimeFromUtc(_clock.GetUtcNow().UtcDateTime, zone);
|
||||
return TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local.Date.AddDays(1), DateTimeKind.Unspecified), zone);
|
||||
}
|
||||
|
||||
private void MoveTo(DateTime utc) => _clock.SetUtcNow(new DateTimeOffset(utc, TimeSpan.Zero));
|
||||
|
||||
// ═══════════════════ one-time tasks: carried over ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_one_time_task_is_carried_over_to_the_next_day()
|
||||
{
|
||||
await SeedOneTime("t-report");
|
||||
var dispatcher = new BudgetDispatcher { HasBudget = false };
|
||||
var scanner = Scanner(dispatcher);
|
||||
var midnight = NextLocalMidnightUtc();
|
||||
|
||||
await scanner.ScanOnceAsync(default);
|
||||
|
||||
var task = await Get("t-report");
|
||||
task.Status.ShouldBe(TaskItemStatus.Todo, "it is still work to be done");
|
||||
task.BudgetMissCount.ShouldBe(1);
|
||||
task.LastBudgetMissAt.ShouldNotBeNull();
|
||||
task.DeferredUntil.ShouldBe(midnight);
|
||||
task.IsCarriedOver.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_carried_over_task_is_not_retried_the_same_day()
|
||||
{
|
||||
await SeedOneTime("t-report");
|
||||
var dispatcher = new BudgetDispatcher { HasBudget = false };
|
||||
var scanner = Scanner(dispatcher);
|
||||
await scanner.ScanOnceAsync(default);
|
||||
|
||||
// Before #716: again after 30 minutes, and again, until midnight.
|
||||
MoveTo(NextLocalMidnightUtc().AddMinutes(-1));
|
||||
(await scanner.ScanOnceAsync(default)).ShouldBe(0);
|
||||
|
||||
dispatcher.Calls.ShouldBe(1, "one refusal, not one every half hour");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task On_the_next_day_it_runs_and_the_miss_is_settled()
|
||||
{
|
||||
await SeedOneTime("t-report");
|
||||
var dispatcher = new BudgetDispatcher { HasBudget = false };
|
||||
var scanner = Scanner(dispatcher);
|
||||
await scanner.ScanOnceAsync(default);
|
||||
|
||||
MoveTo(NextLocalMidnightUtc().AddMinutes(1));
|
||||
dispatcher.HasBudget = true;
|
||||
(await scanner.ScanOnceAsync(default)).ShouldBe(1);
|
||||
|
||||
var task = await Get("t-report");
|
||||
task.Status.ShouldBe(TaskItemStatus.Done);
|
||||
task.BudgetMissCount.ShouldBe(0);
|
||||
task.DeferredUntil.ShouldBeNull();
|
||||
task.LastBudgetMissAt.ShouldNotBeNull("kept as history");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_new_day_has_right_of_way_over_carried_over_work()
|
||||
{
|
||||
await SeedOneTime("t-yesterday", priority: 5);
|
||||
var dispatcher = new BudgetDispatcher { HasBudget = false };
|
||||
var scanner = Scanner(dispatcher);
|
||||
await scanner.ScanOnceAsync(default);
|
||||
|
||||
MoveTo(NextLocalMidnightUtc().AddMinutes(1));
|
||||
await SeedOneTime("t-today", priority: 1);
|
||||
dispatcher.HasBudget = true;
|
||||
dispatcher.Started.Clear();
|
||||
await scanner.ScanOnceAsync(default);
|
||||
|
||||
dispatcher.Started.ShouldBe(["t-today", "t-yesterday"],
|
||||
"carried-over work comes after the day's own, even with a higher priority");
|
||||
}
|
||||
|
||||
// ═══════════════════ recurring tasks: the agent's policy ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Under_expire_a_refused_occurrence_lapses_and_is_recorded()
|
||||
{
|
||||
await SeedHourly("t-hourly");
|
||||
var dispatcher = new BudgetDispatcher { HasBudget = false };
|
||||
var scanner = Scanner(dispatcher, BudgetMissPolicy.Expire);
|
||||
|
||||
await scanner.ScanOnceAsync(default);
|
||||
|
||||
var task = await Get("t-hourly");
|
||||
task.Status.ShouldBe(TaskItemStatus.Todo);
|
||||
task.BudgetMissCount.ShouldBe(1);
|
||||
task.CatchUpPending.ShouldBeFalse();
|
||||
|
||||
// Nothing is caught up the next day beyond the regular occurrences.
|
||||
MoveTo(NextLocalMidnightUtc().AddMinutes(30));
|
||||
dispatcher.HasBudget = true;
|
||||
dispatcher.Started.Clear();
|
||||
(await scanner.ScanOnceAsync(default)).ShouldBe(1, "only the regular occurrence of this hour");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Under_catch_up_once_several_misses_leave_one_pending_catch_up()
|
||||
{
|
||||
await SeedHourly("t-hourly");
|
||||
var dispatcher = new BudgetDispatcher { HasBudget = false };
|
||||
var scanner = Scanner(dispatcher, BudgetMissPolicy.CatchUpOnce);
|
||||
var midnight = NextLocalMidnightUtc();
|
||||
|
||||
// Three hours in a row refused.
|
||||
for (var hour = 0; hour < 3; hour++)
|
||||
{
|
||||
await scanner.ScanOnceAsync(default);
|
||||
_clock.Advance(TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
var task = await Get("t-hourly");
|
||||
task.BudgetMissCount.ShouldBe(3);
|
||||
task.CatchUpPending.ShouldBeTrue();
|
||||
task.DeferredUntil.ShouldBe(midnight, "set by the first miss, not pushed on by the later ones");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_catch_up_runs_exactly_once_on_the_next_day()
|
||||
{
|
||||
// A daily report at 22:30: its next regular occurrence is 24 hours away, so here
|
||||
// the catch-up is the only run — the case catchUpOnce exists for.
|
||||
MoveTo(new DateTime(2027, 3, 10, 22, 30, 0, DateTimeKind.Utc));
|
||||
await _repo.UpsertAsync(new TaskItem
|
||||
{
|
||||
Id = "t-daily", Title = "t-daily", Assignee = "@worker",
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "30 22 * * *", TimeZone = "UTC" }
|
||||
}, default);
|
||||
|
||||
var dispatcher = new BudgetDispatcher { HasBudget = false };
|
||||
var scanner = Scanner(dispatcher, BudgetMissPolicy.CatchUpOnce);
|
||||
(await scanner.ScanOnceAsync(default)).ShouldBe(1, "the regular 22:30 run, refused");
|
||||
|
||||
MoveTo(NextLocalMidnightUtc().AddMinutes(1));
|
||||
dispatcher.HasBudget = true;
|
||||
dispatcher.Started.Clear();
|
||||
|
||||
(await scanner.ScanOnceAsync(default)).ShouldBe(1, "the catch-up");
|
||||
_clock.Advance(TimeSpan.FromMinutes(1));
|
||||
(await scanner.ScanOnceAsync(default)).ShouldBe(0, "once, not on every tick");
|
||||
|
||||
dispatcher.Started.ShouldBe(["t-daily"]);
|
||||
var task = await Get("t-daily");
|
||||
task.CatchUpPending.ShouldBeFalse();
|
||||
task.BudgetMissCount.ShouldBe(0);
|
||||
task.Status.ShouldBe(TaskItemStatus.Todo, "still recurring");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_pending_catch_up_does_not_run_on_the_same_day()
|
||||
{
|
||||
await SeedHourly("t-hourly");
|
||||
var dispatcher = new BudgetDispatcher { HasBudget = false };
|
||||
var scanner = Scanner(dispatcher, BudgetMissPolicy.CatchUpOnce);
|
||||
await scanner.ScanOnceAsync(default);
|
||||
|
||||
// Budget raised by the human in the evening: the regular occurrence runs, and it
|
||||
// covers the pending catch-up — one run, not two.
|
||||
dispatcher.HasBudget = true;
|
||||
dispatcher.Started.Clear();
|
||||
_clock.Advance(TimeSpan.FromHours(1));
|
||||
(await scanner.ScanOnceAsync(default)).ShouldBe(1);
|
||||
(await Get("t-hourly")).CatchUpPending.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_shutdown_is_not_a_budget_miss()
|
||||
{
|
||||
await SeedOneTime("t-report");
|
||||
var dispatcher = new BudgetDispatcher { Result = TaskLauf.NichtGelaufen };
|
||||
await Scanner(dispatcher).ScanOnceAsync(default);
|
||||
|
||||
var task = await Get("t-report");
|
||||
task.BudgetMissCount.ShouldBe(0);
|
||||
task.DeferredUntil.ShouldBeNull("a shutdown keeps the old 30-minute retry");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Late_work_waits_while_the_headroom_gate_says_no()
|
||||
{
|
||||
await SeedOneTime("t-yesterday");
|
||||
var dispatcher = new BudgetDispatcher { HasBudget = false };
|
||||
var gate = new FixedGate { Room = false };
|
||||
var scanner = new TaskScanner(_repo, dispatcher, NullLoggerFactory.Instance,
|
||||
clock: _clock, lateRunGate: gate);
|
||||
await scanner.ScanOnceAsync(default);
|
||||
|
||||
MoveTo(NextLocalMidnightUtc().AddMinutes(1));
|
||||
await SeedOneTime("t-today");
|
||||
dispatcher.HasBudget = true;
|
||||
dispatcher.Started.Clear();
|
||||
|
||||
await scanner.ScanOnceAsync(default);
|
||||
dispatcher.Started.ShouldBe(["t-today"], "regular work never asks the gate");
|
||||
gate.Asked.ShouldBe(["t-yesterday"]);
|
||||
|
||||
gate.Room = true;
|
||||
await scanner.ScanOnceAsync(default);
|
||||
dispatcher.Started.ShouldBe(["t-today", "t-yesterday"]);
|
||||
}
|
||||
|
||||
private sealed class FixedGate : ILateRunGate
|
||||
{
|
||||
public bool Room { get; set; }
|
||||
public List<string> Asked { get; } = [];
|
||||
|
||||
public Task<bool> HasRoomAsync(TaskItem lateTask, IReadOnlyList<TaskItem> runnable, DateTime nowUtc, CancellationToken ct)
|
||||
{
|
||||
Asked.Add(lateTask.Id);
|
||||
return Task.FromResult(Room);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════ test double ═══════════════════
|
||||
|
||||
private sealed class BudgetDispatcher : ITaskDispatcher
|
||||
{
|
||||
public bool HasBudget { get; set; } = true;
|
||||
public TaskLauf? Result { get; set; }
|
||||
public int Calls { get; private set; }
|
||||
public List<string> Started { get; } = [];
|
||||
|
||||
public Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
|
||||
{
|
||||
Calls++;
|
||||
Started.Add(task.Id);
|
||||
return Task.FromResult(Result ?? (HasBudget ? TaskLauf.Erledigt : TaskLauf.KeinBudget));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,24 @@ public sealed class DublettenTests : IDisposable
|
||||
(await _board.ListAsync(new TaskQuery { Limit = 50 }, default)).Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Dublette_wird_auch_hinter_500_anderen_Aufgaben_gefunden()
|
||||
{
|
||||
// #716: Die Suche las ListAsync mit Limit 500, sortiert nach Prioritaet. Eine alte
|
||||
// Aufgabe mit niedriger Prioritaet fiel damit aus dem Blick, sobald das Brett wuchs.
|
||||
var alt = await _board.CreateAsync(new TaskItem
|
||||
{
|
||||
Title = "Betriebsluecke: Eur Kurse nicht aktuell", Assignee = "@human", Priority = 1
|
||||
}, default);
|
||||
for (var i = 0; i < 510; i++)
|
||||
await _repo.UpsertAsync(new TaskItem { Id = $"t-fill-{i}", Title = $"Anderes Thema {i}", Priority = 5 }, default);
|
||||
|
||||
var result = await Run("""{"action":"task_create","title":"Betriebsluecke: Kurse EUR nicht aktuell","assignee":"@human"}""");
|
||||
|
||||
result.Success.ShouldBeFalse("die alte Aufgabe meint dasselbe");
|
||||
result.ErrorMessage.ShouldNotBeNull().ShouldContain(alt.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_erledigte_Aufgabe_blockiert_den_naechsten_Befund_nicht()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user