Eskalation, Konflikt und Beschluss als eigene Vorgaenge (#689, Stufe 1+2)
- Cases/Decisions in der Instanz-DB; CaseDesk fuehrt die Regeln, der Brett-Task traegt den Vorgang zum jeweiligen Halter - Eskalation nur nach oben, eine Ebene je Schritt; die Leitung entscheidet (mit Begruendung, bindet ihren Bereich), erledigt selbst oder reicht mit Einschaetzung weiter - Erst nachsehen: bindende Beschluesse werden vor dem Eskalieren gezeigt; dieselbe Frage aus derselben Einheit schliesst sich dem offenen Vorgang an - Tagesdeckel je Einheit; nach der Frist wird ein Mensch gerufen (Anschluss fuer #644) - Konflikt: zuerst die Gegenseite, dann Zustellung an die niedrigste gemeinsame Leitung; Ebenen dazwischen vermerkt, nicht befragt - Fremdinhalt-Stufe (#715): Eskalation ohne eigenen Text, kein Konflikt mit Position - Jede Ebene haengt an (#697), beim Halter gerahmt als Daten - Escalation ist ein implizites Werkzeug jedes Agenten; Briefing nennt den Weg - Scanner behaelt einen im Lauf gesetzten Status blockiert/abgebrochen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c03664cf29
commit
36fe1ad2aa
@@ -0,0 +1,311 @@
|
||||
using ClawdDotNet.Core.Cases;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Cases;
|
||||
|
||||
/// <summary>
|
||||
/// #689: Escalation, conflict and decision as cases of their own — only up, delivered where
|
||||
/// the tree says, looked up before asking again, and never left unsolved.
|
||||
/// </summary>
|
||||
public sealed class CaseDeskTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "cases-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
private readonly SqliteTaskRepository _tasks;
|
||||
private readonly SqliteCaseRepository _cases;
|
||||
private readonly TaskboardService _board;
|
||||
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2027, 3, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly CaseSettings _settings = new() { DeadlineHours = 4, EscalationsPerUnitPerDay = 10 };
|
||||
private readonly List<CaseRecord> _called = [];
|
||||
private readonly CaseDesk _desk;
|
||||
|
||||
private static readonly AgentTrust ReadsWeb = new(TrustTier.ReadsForeignContent, ["WebFetch"]);
|
||||
|
||||
/// <summary>purchasing (buy-lead) › team-a (a-lead; buyer-a, buyer-c), team-b (b-lead; buyer-b).</summary>
|
||||
private readonly OrgChart _org = new(
|
||||
[
|
||||
new UnitConfig { Id = "purchasing", Name = "Purchasing", LeaderAgentId = "buy-lead" },
|
||||
new UnitConfig { Id = "team-a", Name = "Team A", ParentId = "purchasing", LeaderAgentId = "a-lead" },
|
||||
new UnitConfig { Id = "team-b", Name = "Team B", ParentId = "purchasing", LeaderAgentId = "b-lead" }
|
||||
],
|
||||
[
|
||||
new AgentConfig { AgentId = "buy-lead", UnitId = "purchasing" },
|
||||
new AgentConfig { AgentId = "a-lead", UnitId = "team-a" },
|
||||
new AgentConfig { AgentId = "buyer-a", UnitId = "team-a" },
|
||||
new AgentConfig { AgentId = "buyer-c", UnitId = "team-a" },
|
||||
new AgentConfig { AgentId = "b-lead", UnitId = "team-b" },
|
||||
new AgentConfig { AgentId = "buyer-b", UnitId = "team-b" }
|
||||
]);
|
||||
|
||||
public CaseDeskTests()
|
||||
{
|
||||
var storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
||||
_tasks = new SqliteTaskRepository(storage);
|
||||
_cases = new SqliteCaseRepository(storage);
|
||||
_board = new TaskboardService(_tasks, Path.Combine(_directory, "tasks"));
|
||||
_desk = new CaseDesk(_cases, _board, () => _org, () => _settings, NullLogger.Instance, _clock,
|
||||
(record, _) => { _called.Add(record); return Task.CompletedTask; });
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleaning up is secondary */ }
|
||||
}
|
||||
|
||||
private Task<TaskItem> Stuck(string agent, string id = "t-1")
|
||||
=> _board.CreateAsync(new TaskItem { Id = id, Title = "Record the delivery date", Assignee = "@" + agent }, default);
|
||||
|
||||
private Task<CaseOutcome> Escalate(string agent, string subject = "Delivery date field in orders", string? task = null,
|
||||
bool force = false, AgentTrust? trust = null)
|
||||
=> _desk.EscalateAsync(new EscalationRequest(agent, trust ?? AgentTrust.Standard, subject,
|
||||
"The order confirmation has a delivery date, but the order has no field for it.",
|
||||
"Looked at the columns of the orders table: no date besides created_at.", "A field, or a place to put it.",
|
||||
task, "run-1", force), default);
|
||||
|
||||
// ═══════════════════ escalation ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task An_escalation_goes_to_the_own_lead_and_the_stuck_task_waits()
|
||||
{
|
||||
await Stuck("buyer-a");
|
||||
|
||||
var outcome = await Escalate("buyer-a", task: "t-1");
|
||||
|
||||
outcome.Ok.ShouldBeTrue(outcome.Message);
|
||||
var record = outcome.Case!;
|
||||
record.Holder.ShouldBe("a-lead");
|
||||
record.DeadlineUtc.ShouldBe(_clock.GetUtcNow().UtcDateTime.AddHours(4));
|
||||
(await _tasks.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Blocked);
|
||||
|
||||
var inbox = (await _tasks.GetAsync(record.HolderTaskId, default))!;
|
||||
inbox.Assignee.ShouldBe("@a-lead");
|
||||
inbox.OriginTaskId.ShouldBe("t-1", "#697: the origin travels with it");
|
||||
inbox.Body.ShouldContain("<untrusted_content source=\"case:buyer-a\">", Case.Sensitive, "what a level says is data for the next");
|
||||
inbox.Body.ShouldContain("forward (assessment)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_decision_closes_the_case_binds_the_area_and_sends_the_stuck_task_back_to_work()
|
||||
{
|
||||
await Stuck("buyer-a");
|
||||
var record = (await Escalate("buyer-a", task: "t-1")).Case!;
|
||||
|
||||
var decided = await _desk.DecideAsync(record.Id, "a-lead", "Put it into the note field as 'delivery: <date>'.",
|
||||
"A new column is DevOps' call and takes weeks; the note field is read by the warehouse.", "run-2", default);
|
||||
|
||||
decided.Ok.ShouldBeTrue(decided.Message);
|
||||
decided.Decision!.ScopeUnitId.ShouldBe("team-a");
|
||||
var stuck = (await _tasks.GetAsync("t-1", default))!;
|
||||
stuck.Status.ShouldBe(TaskItemStatus.Todo);
|
||||
stuck.Body.ShouldContain(decided.Decision.Id);
|
||||
(await _cases.GetAsync(record.Id, default))!.Status.ShouldBe(CaseStatus.Decided);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Only_the_holder_decides_and_never_without_reasons()
|
||||
{
|
||||
var record = (await Escalate("buyer-a")).Case!;
|
||||
|
||||
(await _desk.DecideAsync(record.Id, "buyer-c", "Yes", "Because.", null, default)).Ok.ShouldBeFalse();
|
||||
(await _desk.DecideAsync(record.Id, "a-lead", "Yes", "", null, default)).Message.ShouldContain("reasoning");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_next_one_with_the_same_question_is_shown_the_decision_first()
|
||||
{
|
||||
var first = (await Escalate("buyer-a")).Case!;
|
||||
await _desk.DecideAsync(first.Id, "a-lead", "Use the note field.", "No new column for now.", null, default);
|
||||
|
||||
var again = await Escalate("buyer-c", subject: "Delivery date field orders");
|
||||
|
||||
again.Ok.ShouldBeFalse();
|
||||
again.Message.ShouldContain("Use the note field.");
|
||||
(await Escalate("buyer-c", subject: "Delivery date field orders", force: true)).Ok
|
||||
.ShouldBeTrue("with force, when it truly does not fit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_decision_binds_nobody_outside_the_deciding_area()
|
||||
{
|
||||
var first = (await Escalate("buyer-a")).Case!;
|
||||
await _desk.DecideAsync(first.Id, "a-lead", "Use the note field.", "No new column for now.", null, default);
|
||||
|
||||
(await _desk.FindDecisionsAsync("buyer-b", "Delivery date field in orders", null, default)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Passing_on_goes_one_level_up_and_adds_rather_than_replaces()
|
||||
{
|
||||
var record = (await Escalate("buyer-a")).Case!;
|
||||
|
||||
var forwarded = await _desk.ForwardAsync(record.Id, "a-lead", AgentTrust.Standard,
|
||||
"Worth a column: every team needs it.", "run-2", default);
|
||||
|
||||
forwarded.Case!.Holder.ShouldBe("buy-lead");
|
||||
forwarded.Case.Level.ShouldBe(1);
|
||||
forwarded.Case.Trail.Select(e => e.Kind).ShouldBe(["opened", "forwarded"]);
|
||||
var inbox = (await _tasks.GetAsync(forwarded.Case.HolderTaskId, default))!.Body;
|
||||
inbox.ShouldContain("The order confirmation has a delivery date", Case.Sensitive, "the original stays");
|
||||
inbox.ShouldContain("Worth a column");
|
||||
(await _desk.ForwardAsync(record.Id, "a-lead", AgentTrust.Standard, "", null, default)).Ok
|
||||
.ShouldBeFalse("it is no longer with a-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_same_question_from_the_same_unit_joins_the_open_case()
|
||||
{
|
||||
var first = (await Escalate("buyer-a")).Case!;
|
||||
await Stuck("buyer-c", "t-2");
|
||||
|
||||
var second = await Escalate("buyer-c", task: "t-2");
|
||||
|
||||
second.Case!.Id.ShouldBe(first.Id);
|
||||
second.Case.Trail.Last().Kind.ShouldBe("joined");
|
||||
(await _tasks.GetAsync("t-2", default))!.Status.ShouldBe(TaskItemStatus.Blocked);
|
||||
(await _cases.ListOpenAsync(default)).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Beyond_the_daily_cap_of_a_unit_it_goes_straight_to_a_human()
|
||||
{
|
||||
_settings.EscalationsPerUnitPerDay = 2;
|
||||
(await Escalate("buyer-a", subject: "First matter")).Case!.Holder.ShouldBe("a-lead");
|
||||
(await Escalate("buyer-a", subject: "Second matter")).Case!.Holder.ShouldBe("a-lead");
|
||||
|
||||
var third = (await Escalate("buyer-a", subject: "Third matter")).Case!;
|
||||
|
||||
third.HeldByHuman.ShouldBeTrue();
|
||||
third.Trail.ShouldContain(e => e.Kind == "note" && e.Text.Contains("cap"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task After_the_deadline_a_human_is_called_once()
|
||||
{
|
||||
var record = (await Escalate("buyer-a")).Case!;
|
||||
|
||||
_clock.Advance(TimeSpan.FromHours(4) + TimeSpan.FromMinutes(1));
|
||||
await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default);
|
||||
_clock.Advance(TimeSpan.FromMinutes(5));
|
||||
await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default);
|
||||
|
||||
_called.ShouldHaveSingleItem().Id.ShouldBe(record.Id);
|
||||
var now = (await _cases.GetAsync(record.Id, default))!;
|
||||
now.HeldByHuman.ShouldBeTrue();
|
||||
(await _tasks.GetAsync(now.HolderTaskId, default))!.Assignee.ShouldBe(TaskAssignee.Human);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_agent_reading_foreign_content_escalates_without_text_of_its_own()
|
||||
{
|
||||
(await Escalate("buyer-a", trust: ReadsWeb)).Ok.ShouldBeFalse("without a task there is nothing to point to");
|
||||
|
||||
await Stuck("buyer-a");
|
||||
var record = (await Escalate("buyer-a", task: "t-1", trust: ReadsWeb)).Case!;
|
||||
|
||||
record.Subject.ShouldBe("t-1 is stuck");
|
||||
record.Trail.Single().Text.ShouldNotContain("order confirmation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handling_it_closes_the_case_and_the_opener_hears_of_it()
|
||||
{
|
||||
var record = (await Escalate("buyer-a")).Case!;
|
||||
|
||||
(await _desk.HandleAsync(record.Id, "a-lead", "Asked DevOps myself; column comes Friday.", null, default)).Ok.ShouldBeTrue();
|
||||
|
||||
(await _board.ListAllAsync(default)).ShouldContain(t => t.Assignee == "@buyer-a" && t.Title.StartsWith("Outcome of case"));
|
||||
}
|
||||
|
||||
// ═══════════════════ conflict ═══════════════════
|
||||
|
||||
private Task<CaseOutcome> Conflict(string from = "a-lead", string with = "b-lead")
|
||||
=> _desk.OpenConflictAsync(new ConflictRequest(from, AgentTrust.Standard, with, "Delivery date column",
|
||||
"We need the column: three teams type dates into notes.", null, "run-1"), default);
|
||||
|
||||
[Fact]
|
||||
public async Task A_conflict_asks_the_other_side_first_then_reaches_the_lowest_common_lead()
|
||||
{
|
||||
var opened = await Conflict();
|
||||
|
||||
opened.Ok.ShouldBeTrue(opened.Message);
|
||||
var record = opened.Case!;
|
||||
record.Holder.ShouldBe("buy-lead");
|
||||
record.HolderTaskId.ShouldBeEmpty("nobody decides before both sides are heard");
|
||||
var ask = (await _tasks.GetAsync(record.PendingStatements["b-lead"], default))!;
|
||||
ask.Assignee.ShouldBe("@b-lead");
|
||||
|
||||
(await _desk.DecideAsync(record.Id, "buy-lead", "x", "y", null, default)).Ok.ShouldBeFalse("b-lead has not spoken yet");
|
||||
|
||||
var stated = await _desk.StatePositionAsync(record.Id, "b-lead", AgentTrust.Standard,
|
||||
"Notes are enough; a column costs a migration.", "run-2", default);
|
||||
|
||||
stated.Case!.HolderTaskId.ShouldNotBeEmpty();
|
||||
var inbox = (await _tasks.GetAsync(stated.Case.HolderTaskId, default))!;
|
||||
inbox.Assignee.ShouldBe("@buy-lead");
|
||||
inbox.Body.ShouldContain("three teams type dates");
|
||||
inbox.Body.ShouldContain("a column costs a migration");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Both_sides_hear_the_decision()
|
||||
{
|
||||
var record = (await Conflict()).Case!;
|
||||
await _desk.StatePositionAsync(record.Id, "b-lead", AgentTrust.Standard, "Notes are enough.", null, default);
|
||||
|
||||
(await _desk.DecideAsync(record.Id, "buy-lead", "Add the column.", "Three teams need it daily.", null, default)).Ok.ShouldBeTrue();
|
||||
|
||||
var outcomes = (await _board.ListAllAsync(default)).Where(t => t.Title.StartsWith("Outcome of case")).Select(t => t.Assignee);
|
||||
outcomes.ShouldBe(["@a-lead", "@b-lead"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_conflict_cannot_be_passed_on_or_handled_it_is_decided()
|
||||
{
|
||||
var record = (await Conflict()).Case!;
|
||||
await _desk.StatePositionAsync(record.Id, "b-lead", AgentTrust.Standard, "No.", null, default);
|
||||
|
||||
(await _desk.ForwardAsync(record.Id, "buy-lead", AgentTrust.Standard, "", null, default)).Ok.ShouldBeFalse();
|
||||
(await _desk.HandleAsync(record.Id, "buy-lead", "done", null, default)).Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_side_that_does_not_answer_does_not_hold_the_case_up_for_ever()
|
||||
{
|
||||
var record = (await Conflict()).Case!;
|
||||
|
||||
_clock.Advance(TimeSpan.FromHours(5));
|
||||
await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default);
|
||||
|
||||
var now = (await _cases.GetAsync(record.Id, default))!;
|
||||
now.HeldByHuman.ShouldBeTrue();
|
||||
now.PendingStatements.ShouldBeEmpty();
|
||||
(await _tasks.GetAsync(record.PendingStatements["b-lead"], default))!.Status.ShouldBe(TaskItemStatus.Canceled);
|
||||
}
|
||||
|
||||
// ═══════════════════ the human ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task A_human_asks_one_side_for_a_statement_and_decides_after_it()
|
||||
{
|
||||
var record = (await Escalate("buy-lead", subject: "Budget for a new supplier")).Case!;
|
||||
record.HeldByHuman.ShouldBeTrue("buy-lead has no lead above it");
|
||||
|
||||
var asked = await _desk.RequestStatementAsync(record.Id, "a-lead", "How often does team A order from them?", default);
|
||||
asked.Ok.ShouldBeTrue(asked.Message);
|
||||
|
||||
await _desk.StatePositionAsync(record.Id, "a-lead", AgentTrust.Standard, "Twice a week.", null, default);
|
||||
var humanTasks = (await _board.ListAllAsync(default)).Count(t => t.Assignee == TaskAssignee.Human);
|
||||
humanTasks.ShouldBe(1, "the case stays in the one inbox it is in");
|
||||
|
||||
(await _desk.DecideAsync(record.Id, CaseRules.Human, "Approved.", "Twice a week is worth it.", null, default))
|
||||
.Decision!.ScopeUnitId.ShouldBeNull("a human decides for the whole instance");
|
||||
(await _tasks.GetAsync((await _cases.GetAsync(record.Id, default))!.HolderTaskId, default))!.Status.ShouldBe(TaskItemStatus.Done);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using ClawdDotNet.Core.Cases;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Cases;
|
||||
|
||||
/// <summary>#689: The rules of escalation, conflict and decision — read off the tree, none configured.</summary>
|
||||
public sealed class CaseRulesTests
|
||||
{
|
||||
/// <summary>
|
||||
/// purchasing (lead: buy-lead) › team-a (lead: a-lead; buyer-a), team-b (lead: b-lead; buyer-b);
|
||||
/// devops (lead: ops-lead; dev). No lead above the top units — the top is a human.
|
||||
/// </summary>
|
||||
private static OrgChart House() => new(
|
||||
[
|
||||
new UnitConfig { Id = "purchasing", Name = "Purchasing", LeaderAgentId = "buy-lead" },
|
||||
new UnitConfig { Id = "team-a", Name = "Team A", ParentId = "purchasing", LeaderAgentId = "a-lead" },
|
||||
new UnitConfig { Id = "team-b", Name = "Team B", ParentId = "purchasing", LeaderAgentId = "b-lead" },
|
||||
new UnitConfig { Id = "devops", Name = "DevOps", LeaderAgentId = "ops-lead" }
|
||||
],
|
||||
[
|
||||
new AgentConfig { AgentId = "buy-lead", UnitId = "purchasing" },
|
||||
new AgentConfig { AgentId = "a-lead", UnitId = "team-a" },
|
||||
new AgentConfig { AgentId = "buyer-a", UnitId = "team-a" },
|
||||
new AgentConfig { AgentId = "b-lead", UnitId = "team-b" },
|
||||
new AgentConfig { AgentId = "buyer-b", UnitId = "team-b" },
|
||||
new AgentConfig { AgentId = "ops-lead", UnitId = "devops" },
|
||||
new AgentConfig { AgentId = "dev", UnitId = "devops" }
|
||||
]);
|
||||
|
||||
[Fact]
|
||||
public void An_escalation_goes_to_the_own_lead_one_level_at_a_time()
|
||||
{
|
||||
var org = House();
|
||||
|
||||
CaseRules.NextHolder(org, "buyer-a").ShouldBe("a-lead");
|
||||
CaseRules.NextHolder(org, "a-lead").ShouldBe("buy-lead");
|
||||
CaseRules.NextHolder(org, "buy-lead").ShouldBe(CaseRules.Human, "above the top is a human");
|
||||
CaseRules.NextHolder(null, "anyone").ShouldBe(CaseRules.Human);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_teams_of_one_area_are_decided_by_that_area_s_lead_and_nobody_above_is_bothered()
|
||||
{
|
||||
var route = CaseRules.RouteConflict(House(), "a-lead", "b-lead");
|
||||
|
||||
route.Decider.ShouldBe("buy-lead");
|
||||
route.Informed.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_areas_go_to_the_top()
|
||||
{
|
||||
var route = CaseRules.RouteConflict(House(), "buy-lead", "ops-lead");
|
||||
|
||||
route.DecidedByHuman.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_conflict_is_delivered_directly_and_the_levels_between_are_told_not_asked()
|
||||
{
|
||||
var route = CaseRules.RouteConflict(House(), "buyer-a", "dev");
|
||||
|
||||
route.DecidedByHuman.ShouldBeTrue("purchasing and devops meet only at the top");
|
||||
route.Informed.ShouldBe(["a-lead", "buy-lead", "ops-lead"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Someone_above_the_other_side_decides_it_that_is_no_conflict()
|
||||
{
|
||||
CaseRules.RouteConflict(House(), "a-lead", "buyer-a").Refusal.ShouldNotBeNull().ShouldContain("no conflict");
|
||||
CaseRules.RouteConflict(House(), "buyer-a", "buy-lead").Refusal.ShouldNotBeNull().ShouldContain("escalate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_decision_binds_the_deciding_lead_s_area_and_everything_below()
|
||||
{
|
||||
var org = House();
|
||||
var decision = new Decision { Id = "d-1", ScopeUnitId = CaseRules.ScopeOf(org, "buy-lead") };
|
||||
|
||||
decision.ScopeUnitId.ShouldBe("purchasing");
|
||||
CaseRules.Binds(org, decision, "buyer-a").ShouldBeTrue();
|
||||
CaseRules.Binds(org, decision, "dev").ShouldBeFalse();
|
||||
CaseRules.Binds(org, decision with { ScopeUnitId = CaseRules.ScopeOf(org, CaseRules.Human) }, "dev")
|
||||
.ShouldBeTrue("a human decides for the whole instance");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_decision_is_found_by_its_subject_or_by_telling_words()
|
||||
{
|
||||
var decisions = new[]
|
||||
{
|
||||
new Decision { Id = "d-1", Subject = "Delivery date field in orders", Question = "Where do we record the delivery date?" },
|
||||
new Decision { Id = "d-2", Subject = "Weekly report format" }
|
||||
};
|
||||
|
||||
CaseRules.Find(decisions, "delivery date field orders").Select(d => d.Id).ShouldBe(["d-1"]);
|
||||
CaseRules.Find(decisions, "Order confirmation", "The supplier confirmed a delivery date, where does it go?")
|
||||
.Select(d => d.Id).ShouldBe(["d-1"]);
|
||||
CaseRules.Find(decisions, "Holiday plan").ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// #689/#715: A status the run set itself is the run's word. An agent that escalates, or sets
|
||||
/// its task to blocked to report up, must not see it overwritten with "done" when the run ends.
|
||||
/// </summary>
|
||||
public sealed class RunSetStatusTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||
private readonly SqliteTaskRepository _repo;
|
||||
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2027, 3, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
public RunSetStatusTests()
|
||||
{
|
||||
_repo = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleanup is secondary */ }
|
||||
}
|
||||
|
||||
/// <summary>A run that sets its own task to a status, as the taskboard tool would.</summary>
|
||||
private sealed class SettingDispatcher(SqliteTaskRepository repo, TaskItemStatus? set) : ITaskDispatcher
|
||||
{
|
||||
public async Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
|
||||
{
|
||||
if (set is { } status)
|
||||
await repo.SetStatusAsync(task.Id, status, DateTime.UtcNow, ct);
|
||||
return TaskLauf.Erledigt;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TaskItemStatus> RunWith(TaskItemStatus? set)
|
||||
{
|
||||
await _repo.UpsertAsync(new TaskItem { Id = "t-1", Title = "Work", Assignee = "@worker" }, default);
|
||||
var scanner = new TaskScanner(_repo, new SettingDispatcher(_repo, set), NullLoggerFactory.Instance, clock: _clock);
|
||||
await scanner.ScanOnceAsync(default);
|
||||
return (await _repo.GetAsync("t-1", default))!.Status;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TaskItemStatus.Blocked)]
|
||||
[InlineData(TaskItemStatus.Canceled)]
|
||||
public async Task A_status_the_run_set_itself_is_kept(TaskItemStatus set)
|
||||
=> (await RunWith(set)).ShouldBe(set);
|
||||
|
||||
[Fact]
|
||||
public async Task A_run_that_sets_nothing_is_done()
|
||||
=> (await RunWith(null)).ShouldBe(TaskItemStatus.Done);
|
||||
}
|
||||
Reference in New Issue
Block a user