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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-15 11:23:10 +02:00
co-authored by Claude Opus 5
parent 8ab9d99abe
commit 0b6ea73ec1
42 changed files with 2646 additions and 512 deletions
@@ -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>@&lt;agent&gt;</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.";
}
}
+10
View File
@@ -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;
+141
View File
@@ -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