Entscheidungen aus dem Testbetrieb umgesetzt (#572): #577 #579 #581 #589 #193 #205 #594 #595 #597 #598 #599 #588
- Engine: leere Antworten nachfassen, Aufgaben enden mit Ergebniszeile (EmptyResponse/Incomplete), gleiche Fehlschlaege erkennen (RepeatedFailure), Zeitzeile vor Chatnachrichten, Werkzeugergebnisse gerahmt, Vorpruefung vor dem Staging, Belegkosten wie RunUsage - Staging: gescheiterte Freigaben als Failed; AgentEditor-Aenderungen freigabepflichtig, Selbstbearbeitung gesperrt (allowSelfEdit) - Taskboard: Termine beim Anlegen pruefen, naechster/letzter Lauf, pausieren, Sofortlauf, Kostenprognose, Hinweis auf wachsenden Kontext; Jobs-Reiter und /api/jobs entfernt - Release: Instanzmanager samt Weboberflaeche im Paket, setup.json fragt manager.json ab, Token nicht mehr ins systemd-Journal Version 0.1.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -175,6 +175,10 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
sb.AppendLine(task.Acceptance.Trim());
|
||||
}
|
||||
|
||||
// #577: Eine Aufgabe ist erst erledigt, wenn der Agent es ausdruecklich sagt.
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(LaufAbschluss.ErgebnisVorgabe);
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
@@ -184,23 +188,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
/// </summary>
|
||||
internal static string Zeitangabe(TaskItem task, DateTimeOffset jetzt)
|
||||
{
|
||||
var zoneId = string.IsNullOrWhiteSpace(task.When?.TimeZone) ? TimeZones.LocalIanaId : task.When!.TimeZone;
|
||||
TimeZoneInfo zone;
|
||||
try
|
||||
{
|
||||
zone = TimeZoneInfo.FindSystemTimeZoneById(zoneId);
|
||||
}
|
||||
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
|
||||
{
|
||||
zone = TimeZoneInfo.Local;
|
||||
zoneId = TimeZones.LocalIanaId;
|
||||
}
|
||||
|
||||
var lokal = TimeZoneInfo.ConvertTime(jetzt, zone);
|
||||
var invariant = System.Globalization.CultureInfo.InvariantCulture;
|
||||
var zeile = string.Format(invariant,
|
||||
"Aktuelle Zeit: {0:dd.MM.yyyy HH:mm} Uhr ({1}, UTC{0:zzz}); in UTC {2:yyyy-MM-ddTHH:mm}Z.",
|
||||
lokal, zoneId, jetzt.UtcDateTime);
|
||||
var zeile = ZeitAngabe.Zeile(jetzt, task.When?.TimeZone);
|
||||
|
||||
return task.When is { } when
|
||||
? zeile + $" Termin der Aufgabe: {when.Kind.ToString().ToLowerInvariant()} \"{when.Value}\"."
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Hinweise zu einer Aufgabe, die beim Anlegen und im Scanner dieselben sein sollen.
|
||||
/// </summary>
|
||||
public static class TaskHinweise
|
||||
{
|
||||
/// <summary>
|
||||
/// Eine wiederkehrende Aufgabe mit <c>@<agent></c> laeuft im gespeicherten
|
||||
/// Chatverlauf des Agenten (#598). Im Testbetrieb haengte der Nachrichten-Digest so bei
|
||||
/// jedem Lauf den kompletten RSS-Abruf an; der Prompt wuchs in drei Laeufen von 22.000
|
||||
/// auf 61.000 Tokens, die Kosten je Lauf verdoppelten sich - bei gleicher Arbeit.
|
||||
///
|
||||
/// <para>Technisch ist das richtig, <c>@agent</c> heisst "mit Kontext". Nur sieht es
|
||||
/// beim Anlegen niemand. Deshalb der Hinweis statt eines Verbots: Wer den Verlauf
|
||||
/// braucht, soll ihn haben.</para>
|
||||
/// </summary>
|
||||
public static string? WachsenderKontext(TaskItem task)
|
||||
{
|
||||
if (!task.IsRecurring || task.Type == TaskItemType.ToolJob)
|
||||
return null;
|
||||
|
||||
if (TaskAssignee.KindOf(task.Assignee) != TaskAssigneeKind.Agent)
|
||||
return null;
|
||||
|
||||
var agentId = TaskAssignee.AgentId(task.Assignee);
|
||||
return $"Hinweis: '{task.Assignee}' führt jeden Lauf im gespeicherten Chatverlauf des Agenten fort. "
|
||||
+ "Bei einer wiederkehrenden Aufgabe wächst dieser Verlauf mit jedem Lauf – und mit ihm die "
|
||||
+ "Kosten je Lauf, bis die Kompaktierung greift. Für wiederkehrende Aufgaben ist "
|
||||
+ $"'@new:{agentId}' (frischer Lauf) meist die bessere Wahl.";
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,9 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
/// <summary>Aufgaben, deren ungültiger Cron-Ausdruck bereits gemeldet wurde.</summary>
|
||||
private readonly HashSet<string> _warnedCron = [];
|
||||
|
||||
/// <summary>Wiederkehrende @agent-Aufgaben, deren wachsender Kontext bereits gemeldet wurde (#598).</summary>
|
||||
private readonly HashSet<string> _warnedContext = [];
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _loop;
|
||||
|
||||
@@ -186,6 +189,13 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
task.Id, task.Title, task.When!.Value, cronError);
|
||||
}
|
||||
|
||||
// #598: Laeuft, aber teurer mit jedem Lauf - einmal sagen, nicht bei jedem Takt.
|
||||
if (TaskHinweise.WachsenderKontext(task) is { } hinweis
|
||||
&& _warnedContext.Add(task.Id))
|
||||
{
|
||||
_logger.LogWarning("Aufgabe {TaskId} ({Title}): {Hinweis}", task.Id, task.Title, hinweis);
|
||||
}
|
||||
|
||||
var occ = TaskSchedule.DueOccurrence(task, now);
|
||||
if (occ is null)
|
||||
continue;
|
||||
|
||||
@@ -83,6 +83,147 @@ public static class TaskSchedule
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prueft einen Termin, bevor er gespeichert wird - liefert den Grund, warum er nie
|
||||
/// feuern wuerde, oder <c>null</c> (#597).
|
||||
///
|
||||
/// <para>Ein ungueltiger Termin laesst den Scanner nicht abstuerzen, sondern feuert
|
||||
/// schlicht nie. Beim Anlegen ist der richtige Moment, das zu sagen: Dann sieht es der,
|
||||
/// der ihn eingetragen hat, statt Stunden spaeter niemand.</para>
|
||||
/// </summary>
|
||||
public static string? ValidateWhen(TaskWhen when)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(when.TimeZone) && !TimeZones.IsKnown(when.TimeZone))
|
||||
return $"Die Zeitzone '{when.TimeZone}' ist auf diesem System nicht bekannt (IANA-Schreibweise, z.B. 'Europe/Berlin').";
|
||||
|
||||
switch (when.Kind)
|
||||
{
|
||||
case TaskWhenKind.Cron:
|
||||
try
|
||||
{
|
||||
CronExpression.Parse(when.Value);
|
||||
return null;
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return ex.Message;
|
||||
}
|
||||
|
||||
case TaskWhenKind.Every:
|
||||
return TryParseInterval(when.Value, out _)
|
||||
? null
|
||||
: $"'{when.Value}' ist kein Intervall (erwartet z.B. 30m, 2h oder 1d).";
|
||||
|
||||
case TaskWhenKind.At:
|
||||
return TryResolveInstant(when.Value, when.TimeZone, out _)
|
||||
? null
|
||||
: $"'{when.Value}' ist kein Zeitpunkt (erwartet z.B. 2026-09-15T08:00).";
|
||||
|
||||
default:
|
||||
return "Unbekannte Terminart.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der naechste geplante Lauf in UTC - fuer die Anzeige im Taskboard (#594). <c>null</c>,
|
||||
/// wenn keiner mehr ansteht oder der Termin nicht lesbar ist.
|
||||
/// </summary>
|
||||
public static DateTime? NextOccurrenceUtc(TaskItem task, DateTime nowUtc)
|
||||
{
|
||||
nowUtc = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc);
|
||||
|
||||
if (task.When is not { } when)
|
||||
return task.LastOccurrence is null ? nowUtc : null;
|
||||
|
||||
switch (when.Kind)
|
||||
{
|
||||
case TaskWhenKind.At:
|
||||
return task.LastOccurrence is null && TryResolveInstant(when.Value, when.TimeZone, out var at)
|
||||
? at
|
||||
: null;
|
||||
|
||||
case TaskWhenKind.Every:
|
||||
{
|
||||
if (!TryParseInterval(when.Value, out var interval))
|
||||
return null;
|
||||
|
||||
var anchor = task.LastOccurrence is not null
|
||||
? ParseIso(task.LastOccurrence)
|
||||
: (task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime());
|
||||
var next = anchor + interval;
|
||||
return next < nowUtc ? nowUtc : next;
|
||||
}
|
||||
|
||||
case TaskWhenKind.Cron:
|
||||
return TryPrepareCron(when, out var cron, out var tz) ? NextCronUtc(cron, tz, nowUtc) : null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wie viele Laeufe der Termin im Schnitt je Tag ausloest, gerechnet ueber die naechsten
|
||||
/// <paramref name="days"/> Tage - fuer die Kostenprognose (#595). Einmalige Termine
|
||||
/// liefern 0.
|
||||
/// </summary>
|
||||
public static double RunsPerDay(TaskItem task, DateTime nowUtc, int days = 7)
|
||||
{
|
||||
nowUtc = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc);
|
||||
if (task.When is not { } when || days <= 0)
|
||||
return 0;
|
||||
|
||||
switch (when.Kind)
|
||||
{
|
||||
case TaskWhenKind.Every:
|
||||
return TryParseInterval(when.Value, out var interval) ? TimeSpan.FromDays(1) / interval : 0;
|
||||
|
||||
case TaskWhenKind.Cron:
|
||||
{
|
||||
if (!TryPrepareCron(when, out var cron, out var tz))
|
||||
return 0;
|
||||
|
||||
var end = nowUtc.AddDays(days);
|
||||
var count = 0;
|
||||
var cursor = nowUtc;
|
||||
// Obergrenze: jede Minute ueber sieben Tage sind 10.080 - mehr gibt es nicht.
|
||||
while (count < 100_000 && NextCronUtc(cron, tz, cursor) is { } next && next <= end)
|
||||
{
|
||||
count++;
|
||||
cursor = next;
|
||||
}
|
||||
|
||||
return count / (double)days;
|
||||
}
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryPrepareCron(TaskWhen when, out CronExpression cron, out TimeZoneInfo tz)
|
||||
{
|
||||
cron = null!;
|
||||
tz = null!;
|
||||
try { cron = CronExpression.Parse(when.Value); }
|
||||
catch (FormatException) { return false; }
|
||||
|
||||
if (ResolveTimeZone(when.TimeZone) is not { } zone)
|
||||
return false;
|
||||
|
||||
tz = zone;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static DateTime? NextCronUtc(CronExpression cron, TimeZoneInfo tz, DateTime afterUtc)
|
||||
{
|
||||
var local = Truncate(TimeZoneInfo.ConvertTimeFromUtc(afterUtc, tz));
|
||||
if (cron.GetNextOccurrence(local) is not { } nextLocal)
|
||||
return null;
|
||||
|
||||
return TryToUtc(nextLocal, tz, out var utc) ? utc : null;
|
||||
}
|
||||
|
||||
private static string? DueAt(TaskWhen when, string? marker, DateTime nowUtc)
|
||||
{
|
||||
if (marker is not null) return null; // ein einmaliger Termin feuert genau einmal
|
||||
|
||||
Reference in New Issue
Block a user