#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:
@@ -20,6 +20,12 @@ internal sealed class InMemoryStagingRepository : IStagingRepository
|
||||
public Task<StagedCall?> GetAsync(long id, CancellationToken ct)
|
||||
=> Task.FromResult(_calls.GetValueOrDefault(id));
|
||||
|
||||
public Task<int> CountExecutedAsync(string agentId, string tool, string? action, DateTime since, CancellationToken ct)
|
||||
=> Task.FromResult(_calls.Values.Count(c =>
|
||||
c.Status == StagingStatus.Executed && c.AgentId == agentId && c.Tool == tool
|
||||
&& string.Equals(c.Action ?? "", action ?? "", StringComparison.OrdinalIgnoreCase)
|
||||
&& c.DecidedAt >= since));
|
||||
|
||||
public Task<IReadOnlyList<StagedCall>> ListPendingAsync(CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<StagedCall>>(
|
||||
_calls.Values.Where(c => c.Status == StagingStatus.Pending).OrderBy(c => c.Id).ToList());
|
||||
|
||||
@@ -66,17 +66,78 @@ public sealed class StagingServiceTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Freigabe_weckt_den_Agenten_ueber_einen_Folge_Task()
|
||||
public async Task Eine_erfolgreich_ausgefuehrte_Freigabe_weckt_niemanden()
|
||||
{
|
||||
// #643: Vorher weckte jede Entscheidung den Agenten - 110 Freigaben, 110 Laeufe.
|
||||
var id = await Stage();
|
||||
|
||||
await _service.ApproveAsync(id, "richard", default);
|
||||
|
||||
var tasks = await _tasks.ListAsync(new TaskQuery(), default);
|
||||
tasks.Count.ShouldBe(1);
|
||||
tasks[0].Assignee.ShouldBe("@agent-a", "der vorschlagende Agent wird mit seinem Kontext geweckt");
|
||||
tasks[0].Status.ShouldBe(TaskItemStatus.Todo);
|
||||
tasks[0].Body.ShouldContain("freigegeben");
|
||||
(await _tasks.ListAsync(new TaskQuery(), default)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Viele_Entscheidungen_landen_in_einer_frischen_Sammelaufgabe()
|
||||
{
|
||||
var abgelehnt = await Stage("{\"action\":\"send\",\"to\":\"a@b.de\"}");
|
||||
var ok1 = await Stage("{\"action\":\"send\",\"to\":\"c@d.de\"}");
|
||||
var ok2 = await Stage("{\"action\":\"send\",\"to\":\"e@f.de\"}");
|
||||
|
||||
await _service.RejectAsync(abgelehnt, "richard", "falscher Empfaenger", default);
|
||||
await _service.ApproveAsync(ok1, "richard", default);
|
||||
await _service.ApproveAsync(ok2, "richard", default);
|
||||
|
||||
var task = (await _tasks.ListAsync(new TaskQuery(), default)).ShouldHaveSingleItem();
|
||||
task.Title.ShouldBe(StagingService.SammelTitel);
|
||||
task.Assignee.ShouldBe("@new:agent-a", "frisch wecken, nicht den Chatverlauf fortfuehren");
|
||||
task.Body.ShouldContain($"#{abgelehnt} Mail.send: abgelehnt");
|
||||
task.Body.ShouldContain("falscher Empfaenger");
|
||||
task.Body.ShouldContain($"#{ok1} Mail.send: freigegeben und ausgeführt");
|
||||
task.Body.ShouldContain($"#{ok2} Mail.send: freigegeben und ausgeführt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Die_Sammelaufgabe_wird_erst_nach_einer_Ruhepause_faellig()
|
||||
{
|
||||
var id = await Stage();
|
||||
var vorher = DateTime.UtcNow;
|
||||
|
||||
await _service.RejectAsync(id, "richard", "nein", default);
|
||||
|
||||
var task = (await _tasks.ListAsync(new TaskQuery(), default)).Single();
|
||||
task.When.ShouldNotBeNull().Kind.ShouldBe(TaskWhenKind.At);
|
||||
TaskSchedule.DueOccurrence(task, vorher).ShouldBeNull("im Buendelfenster noch nicht faellig");
|
||||
TaskSchedule.DueOccurrence(task, vorher + StagingService.StandardBuendelFenster + TimeSpan.FromMinutes(1))
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Laeuft_die_Sammelaufgabe_schon_entsteht_fuer_Neues_eine_zweite()
|
||||
{
|
||||
var erste = await Stage();
|
||||
await _service.RejectAsync(erste, "richard", "nein", default);
|
||||
var task = (await _tasks.ListAsync(new TaskQuery(), default)).Single();
|
||||
(await _tasks.TryClaimAsync(task.Id, "2026-01-01T00:00:00.0000000Z", "tok", DateTime.UtcNow, DateTime.UtcNow.AddMinutes(-15), default))
|
||||
.ShouldBeTrue();
|
||||
|
||||
var zweite = await Stage("{\"action\":\"send\",\"to\":\"z@z.de\"}");
|
||||
await _service.RejectAsync(zweite, "richard", "auch nein", default);
|
||||
|
||||
var alle = await _tasks.ListAsync(new TaskQuery(), default);
|
||||
alle.Count.ShouldBe(2);
|
||||
alle.Single(t => t.Id != task.Id).Body.ShouldContain($"#{zweite}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_lange_Entscheidungsrunde_bleibt_ein_kleiner_Auftrag()
|
||||
{
|
||||
for (var i = 0; i < 60; i++)
|
||||
await _service.RejectAsync(await Stage($"{{\"action\":\"send\",\"to\":\"x{i}@y.de\"}}"), "richard", "nein", default);
|
||||
|
||||
var task = (await _tasks.ListAsync(new TaskQuery(), default)).ShouldHaveSingleItem();
|
||||
task.Body.Length.ShouldBeLessThan(20_000);
|
||||
(task.Body.Split("### #").Length - 1).ShouldBe(60, "jede Entscheidung steht drin");
|
||||
(task.Body.Split("<untrusted_content").Length - 1).ShouldBe(20, "ausfuehrlich nur die ersten");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -104,6 +165,7 @@ public sealed class StagingServiceTests : IDisposable
|
||||
(await _staging.GetAsync(id, default))!.Status.ShouldBe(StagingStatus.Rejected);
|
||||
|
||||
var tasks = await _tasks.ListAsync(new TaskQuery(), default);
|
||||
tasks[0].Assignee.ShouldBe("@new:agent-a");
|
||||
tasks[0].Body.ShouldContain("abgelehnt");
|
||||
tasks[0].Body.ShouldContain("zu riskant");
|
||||
_audit.Entries.Single().Summary.ShouldContain("Abgelehnt von richard");
|
||||
@@ -157,7 +219,7 @@ public sealed class StagingServiceTests : IDisposable
|
||||
entry.Summary.ShouldContain("gescheitert");
|
||||
|
||||
var body = (await _tasks.ListAsync(new TaskQuery(), default))[0].Body;
|
||||
body.ShouldContain("Ausführung ist aber gescheitert");
|
||||
body.ShouldContain("die Ausführung ist aber gescheitert");
|
||||
body.ShouldContain("<untrusted_content source=\"Mail\">", Case.Sensitive);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Staging;
|
||||
|
||||
/// <summary>
|
||||
/// Das Freigabemodell #641: Rechte stehen in der Werkzeugkonfiguration des Agenten. Was
|
||||
/// erlaubt ist, laeuft; was nicht erlaubt ist, wird einzeln vorgelegt; was verboten ist, wird
|
||||
/// abgelehnt. "Gleichartiges kuenftig erlauben" erteilt eine widerrufbare Dauerfreigabe.
|
||||
/// Nur Aenderungen an Agenten bleiben ausnahmslos freigabepflichtig.
|
||||
///
|
||||
/// Anlass: 110 Freigaben in zwei Tagen, die die Arbeit blockierten und am Ende ungelesen
|
||||
/// durchgeklickt wurden.
|
||||
/// </summary>
|
||||
public sealed class WerkzeugrechteTests : IDisposable
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly SqliteStorage _storage;
|
||||
private readonly SqliteDauerfreigabeRepository _dauer;
|
||||
private readonly InMemoryStagingRepository _repo = new();
|
||||
|
||||
public WerkzeugrechteTests()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
||||
_dauer = new SqliteDauerfreigabeRepository(_storage);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* Aufräumen ist Nebensache */ }
|
||||
}
|
||||
|
||||
private static Werkzeugrechte Rechte(string[]? erlaubt = null, string[]? verboten = null)
|
||||
=> Werkzeugrechte.Aus(new Dictionary<string, object?>
|
||||
{
|
||||
[Werkzeugrechte.ErlaubtSchluessel] = erlaubt is null ? null : JsonSerializer.SerializeToElement(erlaubt),
|
||||
[Werkzeugrechte.VerbotenSchluessel] = verboten is null ? null : JsonSerializer.SerializeToElement(verboten)
|
||||
}.Where(kv => kv.Value is not null).ToDictionary());
|
||||
|
||||
private StagingGate Gate() => new(new StagingPolicy(), _repo, dauerfreigaben: _dauer);
|
||||
|
||||
private Task<StagingInterception> Aufruf(
|
||||
string operation, Werkzeugrechte rechte, string agent = "datenwart", string tool = "Database",
|
||||
StagingDecision? vorgabe = StagingDecision.Approve)
|
||||
=> Gate().InterceptAsync(agent, "inst", "run", tool,
|
||||
$$"""{"action":"query","sql":"{{operation}} ..."}""", CancellationToken.None, operation, rechte, vorgabe);
|
||||
|
||||
// ─── Einstufung aus der Werkzeugkonfiguration ───
|
||||
|
||||
[Theory]
|
||||
[InlineData("select")]
|
||||
[InlineData("insert")]
|
||||
[InlineData("update")]
|
||||
public async Task Erlaubte_Operationen_laufen_ohne_Rueckfrage(string operation)
|
||||
=> (await Aufruf(operation, Rechte(["select", "insert", "update"]))).Outcome.ShouldBe(StagingOutcome.Proceed);
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_nicht_erlaubte_Operation_wird_einzeln_vorgelegt_statt_verweigert()
|
||||
{
|
||||
// Das Beispiel aus der Entscheidung: darf SELECT, INSERT, UPDATE - braucht einmal ALTER.
|
||||
var ergebnis = await Aufruf("alter", Rechte(["select", "insert", "update"]));
|
||||
|
||||
ergebnis.Outcome.ShouldBe(StagingOutcome.Staged);
|
||||
ergebnis.Message.ShouldContain("nicht zu den erlaubten Operationen");
|
||||
(await _repo.CountPendingAsync(default)).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_verbotene_Operation_wird_gar_nicht_erst_vorgelegt()
|
||||
{
|
||||
var ergebnis = await Aufruf("drop", Rechte(["select"], verboten: ["drop", "truncate"]));
|
||||
|
||||
ergebnis.Outcome.ShouldBe(StagingOutcome.Denied);
|
||||
(await _repo.CountPendingAsync(default)).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Stern_erlaubt_alles_ausser_Verbotenem()
|
||||
{
|
||||
(await Aufruf("alter", Rechte(["*"], verboten: ["drop"]))).Outcome.ShouldBe(StagingOutcome.Proceed);
|
||||
(await Aufruf("drop", Rechte(["*"], verboten: ["drop"]))).Outcome.ShouldBe(StagingOutcome.Denied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ohne_erlaubt_gilt_die_Vorgabe_des_Werkzeugs_wie_bisher()
|
||||
{
|
||||
(await Aufruf("select", Werkzeugrechte.Keine, vorgabe: StagingDecision.Auto)).Outcome.ShouldBe(StagingOutcome.Proceed);
|
||||
(await Aufruf("insert", Werkzeugrechte.Keine, vorgabe: StagingDecision.Approve)).Outcome.ShouldBe(StagingOutcome.Staged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ohne_erlaubt_und_ohne_Vorgabe_gilt_die_Standardregel()
|
||||
{
|
||||
var mail = await Gate().InterceptAsync("a", "i", "r", "Mail", """{"action":"send"}""", default, null, Werkzeugrechte.Keine);
|
||||
mail.Outcome.ShouldBe(StagingOutcome.Staged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Die_Konfiguration_wird_in_jeder_Schreibweise_gelesen()
|
||||
{
|
||||
Werkzeugrechte.Aus(new Dictionary<string, object?> { ["erlaubt"] = "SELECT, Insert" })
|
||||
.IstErlaubt("insert").ShouldBeTrue();
|
||||
Werkzeugrechte.Aus(new Dictionary<string, object?> { ["erlaubt"] = new List<object?> { "delete" } })
|
||||
.IstErlaubt("DELETE").ShouldBeTrue();
|
||||
Werkzeugrechte.Aus(new Dictionary<string, object?>()).IstKonfiguriert.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ─── Agenten veraendern: immer einzeln ───
|
||||
|
||||
[Theory]
|
||||
[InlineData("update_soul")]
|
||||
[InlineData("update_identity")]
|
||||
[InlineData("create_agent")]
|
||||
public async Task Aenderungen_an_Agenten_bleiben_trotz_Stern_und_Dauerfreigabe_freigabepflichtig(string aktion)
|
||||
{
|
||||
await _dauer.ErteilenAsync(new Dauerfreigabe { AgentId = "a", Tool = "AgentEditor", Operation = aktion, ErteiltVon = "x" }, default);
|
||||
|
||||
var ergebnis = await Gate().InterceptAsync("a", "i", "r", "AgentEditor",
|
||||
$$"""{"action":"{{aktion}}"}""", default, aktion, Rechte(["*"]));
|
||||
|
||||
ergebnis.Outcome.ShouldBe(StagingOutcome.Staged);
|
||||
ergebnis.Message.ShouldContain("Änderungen an Agenten");
|
||||
}
|
||||
|
||||
// ─── Dauerfreigaben ───
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_Dauerfreigabe_laesst_die_Operation_dieses_Agenten_kuenftig_laufen()
|
||||
{
|
||||
await _dauer.ErteilenAsync(new Dauerfreigabe { AgentId = "datenwart", Tool = "Database", Operation = "insert", ErteiltVon = "richard" }, default);
|
||||
|
||||
(await Aufruf("insert", Rechte(["select"]))).Outcome.ShouldBe(StagingOutcome.Proceed);
|
||||
(await Aufruf("delete", Rechte(["select"]))).Outcome.ShouldBe(StagingOutcome.Staged, "andere Operation");
|
||||
(await Aufruf("insert", Rechte(["select"]), agent: "datenleser")).Outcome.ShouldBe(StagingOutcome.Staged, "anderer Agent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_Dauerfreigabe_hebt_kein_Verbot_auf()
|
||||
{
|
||||
await _dauer.ErteilenAsync(new Dauerfreigabe { AgentId = "datenwart", Tool = "Database", Operation = "drop", ErteiltVon = "richard" }, default);
|
||||
|
||||
(await Aufruf("drop", Rechte(["select"], verboten: ["drop"]))).Outcome.ShouldBe(StagingOutcome.Denied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nach_dem_Widerruf_wird_wieder_vorgelegt()
|
||||
{
|
||||
var d = await _dauer.ErteilenAsync(new Dauerfreigabe { AgentId = "datenwart", Tool = "Database", Operation = "insert", ErteiltVon = "richard" }, default);
|
||||
(await _dauer.WiderrufenAsync(d.Id, "richard", DateTime.UtcNow, default)).ShouldBeTrue();
|
||||
|
||||
(await Aufruf("insert", Rechte(["select"]))).Outcome.ShouldBe(StagingOutcome.Staged);
|
||||
(await _dauer.ListAsync(mitWiderrufenen: true, default)).Single().Gilt.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dieselbe_Dauerfreigabe_entsteht_nur_einmal()
|
||||
{
|
||||
var a = await _dauer.ErteilenAsync(new Dauerfreigabe { AgentId = "w", Tool = "FileRW", Operation = "Delete", ErteiltVon = "x" }, default);
|
||||
var b = await _dauer.ErteilenAsync(new Dauerfreigabe { AgentId = "w", Tool = "FileRW", Operation = "delete", ErteiltVon = "y" }, default);
|
||||
|
||||
b.Id.ShouldBe(a.Id);
|
||||
(await _dauer.ListAsync(false, default)).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Freigeben_mit_Gleichartiges_erlauben_erteilt_die_Dauerfreigabe()
|
||||
{
|
||||
var staging = new SqliteStagingRepository(_storage);
|
||||
var service = new StagingService(staging, new Ausfuehrer(), new TaskboardService(new SqliteTaskRepository(_storage),
|
||||
Path.Combine(_directory, "tasks")), NullLoggerFactory.Instance, dauerfreigaben: _dauer);
|
||||
var id = await staging.AppendAsync(new StagedCall { RunId = "r", AgentId = "datenwart", Tool = "Database", Action = "insert", ArgumentsJson = "{}" }, default);
|
||||
|
||||
await service.ApproveAsync(id, "richard", gleichartigErlauben: true, default);
|
||||
|
||||
var d = (await service.ListDauerfreigabenAsync(false, default)).ShouldHaveSingleItem();
|
||||
d.AgentId.ShouldBe("datenwart");
|
||||
d.Operation.ShouldBe("insert");
|
||||
d.AnlassId.ShouldBe(id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nach_mehreren_gleichartigen_Freigaben_wird_die_Dauerfreigabe_vorgeschlagen()
|
||||
{
|
||||
var staging = new SqliteStagingRepository(_storage);
|
||||
var service = new StagingService(staging, new Ausfuehrer(), new TaskboardService(new SqliteTaskRepository(_storage),
|
||||
Path.Combine(_directory, "tasks")), NullLoggerFactory.Instance, dauerfreigaben: _dauer);
|
||||
|
||||
StagedCall Vorschlag() => new() { RunId = "r", AgentId = "datenwart", Tool = "Database", Action = "insert", ArgumentsJson = "{}" };
|
||||
for (var i = 0; i < StagingService.VorschlagAb; i++)
|
||||
await service.ApproveAsync(await staging.AppendAsync(Vorschlag(), default), "richard", default);
|
||||
|
||||
var offen = (await staging.GetAsync(await staging.AppendAsync(Vorschlag(), default), default))!;
|
||||
(await service.BisherFreigegebenAsync(offen, default)).ShouldBe(StagingService.VorschlagAb);
|
||||
|
||||
var soul = offen with { Tool = "AgentEditor", Action = "update_soul" };
|
||||
StagingService.IstDauerhaftErlaubbar(soul).ShouldBeFalse("Agenten veraendern wird nie dauerhaft erlaubt");
|
||||
}
|
||||
|
||||
private sealed class Ausfuehrer : IFrozenCallExecutor
|
||||
{
|
||||
public Task<string> ExecuteApprovedCallAsync(string agentId, string tool, string argumentsJson, string runId, CancellationToken ct)
|
||||
=> Task.FromResult("1 Zeilen betroffen.");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ public sealed class ToolJobDispatchTests
|
||||
|
||||
var ok = await Dispatcher(fixture).DispatchAsync(PollTask(), default);
|
||||
|
||||
ok.ShouldBeTrue("ein Poll ohne Fund ist kein Fehler");
|
||||
ok.ShouldBe(TaskLauf.Erledigt, "ein Poll ohne Fund ist kein Fehler");
|
||||
tool.Ticks.ShouldBe(1);
|
||||
fixture.Client.ReceivedRequests.ShouldBeEmpty("kein Agent-Lauf, wenn nichts zu wecken war");
|
||||
}
|
||||
@@ -53,7 +53,7 @@ public sealed class ToolJobDispatchTests
|
||||
|
||||
var ok = await Dispatcher(fixture).DispatchAsync(PollTask(), default);
|
||||
|
||||
ok.ShouldBeTrue();
|
||||
ok.ShouldBe(TaskLauf.Erledigt);
|
||||
tool.Ticks.ShouldBe(1);
|
||||
fixture.Client.ReceivedRequests.ShouldNotBeEmpty("der Agent wurde geweckt");
|
||||
}
|
||||
@@ -64,7 +64,7 @@ public sealed class ToolJobDispatchTests
|
||||
var fixture = new EngineFixture(); // PollTool nicht registriert
|
||||
fixture.AddAgent("agent-a");
|
||||
|
||||
(await Dispatcher(fixture).DispatchAsync(PollTask(), default)).ShouldBeFalse();
|
||||
(await Dispatcher(fixture).DispatchAsync(PollTask(), default)).ShouldBe(TaskLauf.Gescheitert);
|
||||
}
|
||||
|
||||
/// <summary>Ein Tool, das zugleich Poll-Provider ist — zählt Ticks, liefert ein festes Ergebnis.</summary>
|
||||
|
||||
Reference in New Issue
Block a user