#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:
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user