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
@@ -226,6 +226,40 @@ public sealed class TaskScannerTests : IDisposable
TaskItemStatus.Blocked, "nicht alle Blocker sind erledigt");
}
// ─── #643: Nicht gelaufene Aufgaben ───
[Fact]
public async Task Eine_einmalige_Aufgabe_ohne_Budget_bleibt_faellig_statt_liegenzubleiben()
{
await _repo.UpsertAsync(new TaskItem { Id = "t-einmal", Title = "einmal", Assignee = "@new:a", Status = TaskItemStatus.Todo }, default);
var dispatcher = new FakeDispatcher { Ergebnis = TaskLauf.NichtGelaufen };
var scanner = Scanner(dispatcher);
(await scanner.ScanOnceAsync(default)).ShouldBe(1);
var task = (await _repo.GetAsync("t-einmal", default))!;
task.Status.ShouldBe(TaskItemStatus.Todo);
task.LastOccurrence.ShouldBeNull("der Termin gilt als nicht verbraucht");
(await scanner.ScanOnceAsync(default)).ShouldBe(0, "Wiedervorlage erst nach der Pause, nicht im Minutentakt");
_clock.Advance(TaskScanner.Wiedervorlage + TimeSpan.FromMinutes(1));
dispatcher.Ergebnis = TaskLauf.Erledigt;
(await scanner.ScanOnceAsync(default)).ShouldBe(1);
(await _repo.GetAsync("t-einmal", default))!.Status.ShouldBe(TaskItemStatus.Done);
}
[Fact]
public async Task Eine_gescheiterte_einmalige_Aufgabe_bleibt_verbraucht()
{
await _repo.UpsertAsync(new TaskItem { Id = "t-kaputt", Title = "kaputt", Assignee = "@new:a", Status = TaskItemStatus.Todo }, default);
var scanner = Scanner(new FakeDispatcher(succeed: false));
await scanner.ScanOnceAsync(default);
(await _repo.GetAsync("t-kaputt", default))!.LastOccurrence.ShouldNotBeNull("kein Retry-Sturm nach echtem Fehlschlag");
}
// ─── Attrappen ───
private sealed class FakeDispatcher(bool succeed = true) : ITaskDispatcher
@@ -233,11 +267,13 @@ public sealed class TaskScannerTests : IDisposable
private readonly Lock _lock = new();
public List<string> Dispatched { get; } = [];
public Task<bool> DispatchAsync(TaskItem task, CancellationToken ct)
public TaskLauf? Ergebnis { get; set; }
public Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
{
lock (_lock)
Dispatched.Add(task.Id);
return Task.FromResult(succeed);
return Task.FromResult(Ergebnis ?? (succeed ? TaskLauf.Erledigt : TaskLauf.Gescheitert));
}
}