Freigaben buendeln statt Weckflut (#643), Freigabemodell ueber Werkzeugrechte (#641)

#643: Entscheidungen wecken den Agenten nur noch bei Ablehnung oder gescheiterter
Ausfuehrung, gesammelt in einer Aufgabe je Agent (5 min Ruhefenster), frisch per
@new:agent statt im fortgefuehrten Chatverlauf. Eine einmalige Aufgabe, deren Lauf
wegen Budget oder Herunterfahren gar nicht stattfand, bleibt faellig (Wiedervorlage
nach 30 min) statt mit gesetztem Marker liegenzubleiben.

#641: Rechte stehen in der Werkzeugkonfiguration des Agenten (erlaubt/verboten).
Erlaubtes laeuft, Nicht-Erlaubtes wird einzeln vorgelegt statt verweigert, Verbotenes
gar nicht erst. "Gleichartiges kuenftig erlauben" erteilt eine widerrufbare
Dauerfreigabe je Agent/Werkzeug/Operation; nach drei gleichartigen Freigaben schlaegt
die Oberflaeche sie vor. Aenderungen an Agenten bleiben ausnahmslos freigabepflichtig.
Database: Operation ist das SQL-Schluesselwort; die eigene Zugriffspruefung greift nur
noch ohne Freigabestelle (AgentToolContext.FreigabeGeprueft). WebUI: Rechte-Tabelle je
Werkzeug, Dauerfreigaben mit Widerruf.

Version 0.1.11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-19 14:37:44 +02:00
co-authored by Claude Opus 5
parent f74e49ba35
commit 2f0361278b
36 changed files with 1687 additions and 137 deletions
@@ -49,7 +49,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
_clock = clock ?? TimeProvider.System;
}
public async Task<bool> DispatchAsync(TaskItem task, CancellationToken ct)
public async Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
{
// Poll-Tasks (tool_job) ticken ein Tool statt einen Agenten direkt zu starten.
if (task.Type == TaskItemType.ToolJob)
@@ -70,7 +70,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
_logger.LogWarning(
"Aufgabe {TaskId}: Assignee '{Assignee}' nicht auflösbar (frischer Lauf braucht einen eindeutigen Agenten)",
task.Id, task.Assignee);
return false;
return TaskLauf.Gescheitert;
}
result = await _engine.RunAsync(
config, message, _instanceId, ct, source: ChatSource.Task, taskId: task.Id);
@@ -84,7 +84,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
{
_logger.LogWarning(
"Aufgabe {TaskId}: Agent '{AgentId}' nicht gefunden", task.Id, agentId);
return false;
return TaskLauf.Gescheitert;
}
result = await _engine.ChatAsync(
config, message, _instanceId, ct, source: ChatSource.Task, taskId: task.Id);
@@ -92,10 +92,15 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
}
default:
// @human wird vom Scanner gar nicht erst angestoßen.
return false;
return TaskLauf.Gescheitert;
}
return result.Status == AgentRunStatus.Completed;
return result.Status switch
{
AgentRunStatus.Completed => TaskLauf.Erledigt,
AgentRunStatus.BudgetExceeded or AgentRunStatus.ShuttingDown => TaskLauf.NichtGelaufen,
_ => TaskLauf.Gescheitert
};
}
/// <summary>
@@ -103,14 +108,14 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
/// wenn der Tick etwas meldet. Der Tick selbst gilt als erfolgreich, auch wenn nichts
/// zu tun war — ein Poll ohne Fund ist kein Fehler.
/// </summary>
private async Task<bool> DispatchToolJobAsync(TaskItem task, CancellationToken ct)
private async Task<TaskLauf> DispatchToolJobAsync(TaskItem task, CancellationToken ct)
{
if (_toolRegistry.Get(task.ToolName) is not IToolJobProvider provider)
{
_logger.LogWarning(
"Tool-Job {TaskId}: Tool '{Tool}' ist kein IToolJobProvider oder nicht registriert",
task.Id, task.ToolName);
return false;
return TaskLauf.Gescheitert;
}
// Zielagent (zum Wecken) und dessen Tool-Konfiguration/Workspace.
@@ -124,14 +129,14 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
task.JobTypeId, toolConfig, _stateStore, _logger, ct, agentId, config?.WorkspacePath);
if (!result.ShouldWakeAgent || string.IsNullOrWhiteSpace(result.WakeMessage))
return true; // Poll lief, nichts zu wecken
return TaskLauf.Erledigt; // Poll lief, nichts zu wecken
if (config is null)
{
_logger.LogWarning(
"Tool-Job {TaskId} wollte Agent '{AgentId}' wecken, der aber nicht gefunden wurde",
task.Id, agentId);
return true;
return TaskLauf.Erledigt;
}
// Der Provider entscheidet je Tick, ob mit Kontext (ChatAsync) oder zustandslos.
@@ -140,7 +145,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
else
await _engine.RunAsync(config, result.WakeMessage, _instanceId, ct, source: ChatSource.Job, taskId: task.Id);
return true;
return TaskLauf.Erledigt;
}
private static AgentConfig? ResolveFreshAgent(IReadOnlyList<AgentConfig> agents, string agentId)
@@ -44,6 +44,14 @@ public interface ITaskRepository
string id, string claimToken, TaskItemStatus finalStatus,
DateTime now, CancellationToken ct);
/// <summary>
/// Gibt einen Claim zurück, als hätte es ihn nicht gegeben: Status <c>todo</c> und der
/// Marker auf <paramref name="previousOccurrence"/> (#643). Für Läufe, die gar nicht
/// stattfanden. Nur wirksam, solange der Aufrufer den Claim hält.
/// </summary>
Task<bool> ReleaseClaimAsync(
string id, string claimToken, string? previousOccurrence, DateTime now, CancellationToken ct);
/// <summary>Ändert den Status direkt (Tool-Aktionen, Auto-Dispatch, Eskalation).</summary>
Task<bool> SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct);
@@ -162,6 +162,25 @@ public sealed class SqliteTaskRepository : ITaskRepository
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<bool> ReleaseClaimAsync(
string id, string claimToken, string? previousOccurrence, 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
WHERE Id = @id AND ClaimToken = @token
""";
cmd.Parameters.AddWithValue("@occ", (object?)previousOccurrence ?? DBNull.Value);
cmd.Parameters.AddWithValue("@now", Format(now));
cmd.Parameters.AddWithValue("@id", id);
cmd.Parameters.AddWithValue("@token", claimToken);
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<bool> SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
+51 -5
View File
@@ -2,14 +2,30 @@ using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Tasks;
/// <summary>Wie ein Dispatch ausging.</summary>
public enum TaskLauf
{
/// <summary>Der Lauf schloss regulär ab.</summary>
Erledigt,
/// <summary>Der Lauf fand statt und scheiterte.</summary>
Gescheitert,
/// <summary>
/// Der Lauf fand gar nicht statt — Budget erschöpft, Instanz im Herunterfahren (#643).
/// Eine einmalige Aufgabe bleibt dann fällig, statt als gelaufen liegenzubleiben.
/// </summary>
NichtGelaufen
}
/// <summary>
/// Führt eine fällige Aufgabe aus. Kapselt, wie ein Assignee zu einem Lauf wird — der
/// Scanner selbst kennt die Engine nicht und bleibt so ohne sie testbar.
/// </summary>
public interface ITaskDispatcher
{
/// <summary>Führt die Aufgabe aus. Gibt zurück, ob der Lauf regulär abschloss.</summary>
Task<bool> DispatchAsync(TaskItem task, CancellationToken ct);
/// <summary>Führt die Aufgabe aus und meldet, wie der Lauf ausging.</summary>
Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct);
}
/// <summary>
@@ -58,6 +74,16 @@ public sealed class TaskScanner : IAsyncDisposable
/// <summary>Wiederkehrende @agent-Aufgaben, deren wachsender Kontext bereits gemeldet wurde (#598).</summary>
private readonly HashSet<string> _warnedContext = [];
/// <summary>
/// #643: Einmalige Aufgaben, deren Lauf nicht stattfand, und ab wann sie wieder dran sind.
/// Ohne diese Pause würde eine wegen Budget abgewiesene Aufgabe jede Minute erneut
/// anlaufen. Nur im Speicher — nach einem Neustart darf sie einmal sofort versuchen.
/// </summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _zurueckgestellt = new();
/// <summary>Pause, bevor eine nicht gelaufene einmalige Aufgabe erneut versucht wird.</summary>
public static readonly TimeSpan Wiedervorlage = TimeSpan.FromMinutes(30);
private readonly CancellationTokenSource _cts = new();
private Task? _loop;
@@ -196,6 +222,9 @@ public sealed class TaskScanner : IAsyncDisposable
_logger.LogWarning("Aufgabe {TaskId} ({Title}): {Hinweis}", task.Id, task.Title, hinweis);
}
if (_zurueckgestellt.TryGetValue(task.Id, out var ab) && now < ab)
continue;
var occ = TaskSchedule.DueOccurrence(task, now);
if (occ is null)
continue;
@@ -257,10 +286,10 @@ public sealed class TaskScanner : IAsyncDisposable
private async Task ProcessCoreAsync(TaskItem task, string token, CancellationToken ct)
{
bool ok;
TaskLauf lauf;
try
{
ok = await _dispatcher.DispatchAsync(task, ct);
lauf = await _dispatcher.DispatchAsync(task, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
@@ -269,10 +298,27 @@ public sealed class TaskScanner : IAsyncDisposable
catch (Exception ex)
{
_logger.LogError(ex, "Dispatch für Aufgabe {TaskId} ist gescheitert", task.Id);
ok = false;
lauf = TaskLauf.Gescheitert;
}
var now = _clock.GetUtcNow().UtcDateTime;
var ok = lauf == TaskLauf.Erledigt;
// #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)
{
if (await _repo.ReleaseClaimAsync(task.Id, token, task.LastOccurrence, now, ct))
{
_zurueckgestellt[task.Id] = now + Wiedervorlage;
_logger.LogInformation(
"Aufgabe {TaskId} lief nicht (Budget/Herunterfahren) — erneut fällig ab {Ab:HH:mm} UTC",
task.Id, now + Wiedervorlage);
}
return;
}
_zurueckgestellt.TryRemove(task.Id, out _);
// Endstatus:
// - Fehlschlag → zurück auf todo (der Marker steht schon beim Claim, derselbe Termin