diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs
index 2d4278a..9e18bd0 100644
--- a/src/ClawdDotNet.App/AppHost.cs
+++ b/src/ClawdDotNet.App/AppHost.cs
@@ -141,6 +141,9 @@ public sealed class AppHost : IAsyncDisposable
/// #725: Inbound triggers — n8n or any other system hands an agent a task.
public Core.Triggers.InboundTriggers? Triggers { get; private set; }
+ /// #646: Findings on work that lives but does not move.
+ public Core.Tasks.TaskFollowUp? FollowUp { get; private set; }
+
/// #700: Day values and the month archive of audit and receipts.
public Core.Audit.AuditArchive? AuditArchive { get; private set; }
@@ -663,7 +666,7 @@ public sealed class AppHost : IAsyncDisposable
engine.LoadPersistedChats();
- var (scanner, staging, board) = BuildTaskboard(
+ var (scanner, staging, board, followUp) = BuildTaskboard(
instance, taskRepository, engine, tools, stateStore, audit,
stagingRepository, dauerfreigaben, stagingGate, cases, storage, loggerFactory, logger);
@@ -734,6 +737,7 @@ public sealed class AppHost : IAsyncDisposable
loggerFactory.CreateLogger("ClawdDotNet.Core.Audit"));
auditArchive.Start();
host.AuditArchive = auditArchive;
+ host.FollowUp = followUp;
host._shutdown.Add(auditArchive.DisposeAsync);
// #698: what holds in a unit, set by its lead.
@@ -874,7 +878,7 @@ public sealed class AppHost : IAsyncDisposable
}
}
- private static (TaskScanner?, StagingService?, TaskboardService?) BuildTaskboard(
+ private static (TaskScanner?, StagingService?, TaskboardService?, Core.Tasks.TaskFollowUp?) BuildTaskboard(
InstanceConfig instance,
SqliteTaskRepository taskRepository,
AgentEngine engine,
@@ -894,7 +898,7 @@ public sealed class AppHost : IAsyncDisposable
.FirstOrDefault(p => !string.IsNullOrWhiteSpace(p));
if (string.IsNullOrWhiteSpace(sharedWorkspace))
- return (null, null, null);
+ return (null, null, null, null);
var board = new TaskboardService(taskRepository, Path.Combine(sharedWorkspace, "tasks"));
@@ -937,6 +941,10 @@ public sealed class AppHost : IAsyncDisposable
estimator: estimator, fillSettings: () => instance.FillWork)
: null;
+ // #646: the board follows up on stalled, unblocked and waiting work.
+ var followUp = new Core.Tasks.TaskFollowUp(board, taskRepository, audit, storage, () => instance.FollowUp,
+ () => engine.OrganisationProvider?.Invoke(), loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.FollowUp"));
+
var scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory,
maxParallelRuns: instance.MaxParallelTaskRuns,
budgetMissPolicy: task =>
@@ -967,7 +975,9 @@ public sealed class AppHost : IAsyncDisposable
tools.Get,
loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Reports")),
// #689: A case nobody solves in time goes to a human.
- caseDesk),
+ caseDesk,
+ // #646: work that lives but does not move is followed up.
+ followUp),
// #717: Fill work runs on budget the day leaves unused, and never overruns its
// deadline silently — a human is asked in time.
fillGate: headroom,
@@ -1013,7 +1023,7 @@ public sealed class AppHost : IAsyncDisposable
}
});
- return (scanner, staging, board);
+ return (scanner, staging, board, followUp);
}
private async Task ConnectTelegramAsync(Callbacks callbacks, ILogger logger)
diff --git a/src/ClawdDotNet.Core/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs
index cac5e44..fb00da4 100644
--- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs
+++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs
@@ -155,6 +155,10 @@ public sealed class InstanceConfig
[JsonPropertyName("claudeCode")]
public Runtimes.ClaudeCodeSettings ClaudeCode { get; set; } = new();
+ /// #646: When the board follows up on work that lives but does not move.
+ [JsonPropertyName("followUp")]
+ public Tasks.FollowUpSettings FollowUp { get; set; } = new();
+
/// #700: How long runs stay in the working database before they move to the month archive — nothing is deleted.
[JsonPropertyName("auditArchive")]
public Audit.AuditArchiveSettings AuditArchive { get; set; } = new();
diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
index fd841f0..e2de1bd 100644
--- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
+++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs
@@ -302,6 +302,26 @@ public sealed class SqliteStorage
CREATE INDEX IF NOT EXISTS IX_RunDaily_Unit ON RunDaily (UnitId, Day);
+ -- #646: work that lives but does not move — once per kind, task and state it was found in.
+ CREATE TABLE IF NOT EXISTS TaskFindings (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ Kind TEXT NOT NULL,
+ TaskId TEXT NOT NULL,
+ Mark TEXT NOT NULL,
+ Assignee TEXT NOT NULL DEFAULT '',
+ Title TEXT NOT NULL DEFAULT '',
+ Detail TEXT NOT NULL DEFAULT '',
+ SinceAt TEXT NOT NULL,
+ FirstSeenAt TEXT NOT NULL,
+ LastSeenAt TEXT NOT NULL,
+ ActedAt TEXT NULL,
+ Action TEXT NOT NULL DEFAULT '',
+ ResolvedAt TEXT NULL,
+ UNIQUE (Kind, TaskId, Mark)
+ );
+
+ CREATE INDEX IF NOT EXISTS IX_TaskFindings_Open ON TaskFindings (ResolvedAt, ActedAt);
+
-- Staging (A2): eingefrorene, freigabepflichtige Tool-Aufrufe. Ausgeführt wird
-- genau der gespeicherte Argument-JSON (Plan-Freeze).
-- #698: a unit's doctrine, every version kept — who, when, which run, why.
diff --git a/src/ClawdDotNet.Core/Tasks/TaskFollowUp.cs b/src/ClawdDotNet.Core/Tasks/TaskFollowUp.cs
new file mode 100644
index 0000000..d8bb1e4
--- /dev/null
+++ b/src/ClawdDotNet.Core/Tasks/TaskFollowUp.cs
@@ -0,0 +1,376 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ClawdDotNet.Core.Audit;
+using ClawdDotNet.Core.Organisation;
+using ClawdDotNet.Core.Storage;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Core.Tasks;
+
+/// #646: When work counts as left lying (followUp in the instance settings).
+public sealed class FollowUpSettings
+{
+ [JsonPropertyName("enabled")]
+ public bool Enabled { get; set; } = true;
+
+ /// An agent's task in progress without any change for this long.
+ [JsonPropertyName("stalledHours")]
+ public int StalledHours { get; set; } = 24;
+
+ /// A task for a human that waits this long.
+ [JsonPropertyName("humanHours")]
+ public int HumanHours { get; set; } = 48;
+
+ /// A task in review that nobody takes up for this long.
+ [JsonPropertyName("reviewHours")]
+ public int ReviewHours { get; set; } = 48;
+
+ /// Runs of one task that end at the step limit within seven days.
+ [JsonPropertyName("stepLimitRuns")]
+ public int StepLimitRuns { get; set; } = 3;
+
+ /// Follow-ups per pass at most — waking an agent costs a run.
+ [JsonPropertyName("maxActionsPerPass")]
+ public int MaxActionsPerPass { get; set; } = 10;
+}
+
+/// The patterns of work that formally lives but does not move on.
+public static class FollowUpKind
+{
+ public const string Stalled = "stalled";
+ public const string Unblocked = "unblocked";
+ public const string HumanWaiting = "human_waiting";
+ public const string ReviewWaiting = "review_waiting";
+ public const string StepLimit = "step_limit";
+}
+
+/// A finding of one pass: what, which task, since when. is the state it was found in.
+public sealed record TaskFinding(string Kind, string TaskId, string Assignee, string Title, DateTime SinceUtc, string Mark, string Detail);
+
+/// A finding as kept: when first and last seen, what was done, when it resolved.
+public sealed record TaskFindingRecord(
+ long Id, string Kind, string TaskId, string Assignee, string Title, string Detail,
+ DateTime FirstSeenUtc, DateTime LastSeenUtc, DateTime? ActedAtUtc, string Action, DateTime? ResolvedAtUtc);
+
+///
+/// #646: The board follows up on work that lives but does not move: an agent's task in
+/// progress without change, a blocked task whose blockers are long done, a task that waits for
+/// a human or for a review, and a task that keeps ending at the step limit.
+///
+/// This recognises, others interpret (the split with #680). Every pattern is a
+/// query over the board and the receipts — free, reliable, in milliseconds. What the board
+/// cannot judge goes to the lead of the task's unit, or a human. The findings are kept in a
+/// table, so a supervisor has something reliable to read instead of counting itself.
+///
+/// Once per state, braked. A finding acts once for the state the task was found
+/// in; when the task moves and stops again, that is a new finding. A stalled task is woken
+/// once; if it stalls again within a week, its lead hears of it instead. At most
+/// actions per pass — waking costs runs.
+/// Not the claim lease: that clears crashed runs; this is about work that has not crashed.
+///
+public sealed class TaskFollowUp(
+ TaskboardService board,
+ ITaskRepository repo,
+ IAuditRepository audit,
+ SqliteStorage storage,
+ Func settings,
+ Func organisation,
+ ILogger logger) : ITaskTreeWatch
+{
+ public static readonly TimeSpan Interval = TimeSpan.FromMinutes(30);
+
+ public const string Author = "follow-up";
+
+ public const string DigestTitle = "Waiting for you: tasks left lying";
+
+ private DateTime _lastCheckAt = DateTime.MinValue;
+
+ public async Task CheckAsync(DateTime nowUtc, CancellationToken ct)
+ {
+ if (nowUtc - _lastCheckAt < Interval)
+ return;
+ _lastCheckAt = nowUtc;
+ if (!settings().Enabled)
+ return;
+
+ try
+ {
+ await PassAsync(nowUtc, ct);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger.LogWarning(ex, "Follow-up on tasks failed — next attempt in {Minutes} min", Interval.TotalMinutes);
+ }
+ }
+
+ /// One pass: find, keep, act on what is new, resolve what is gone. Returns the actions taken.
+ public async Task PassAsync(DateTime nowUtc, CancellationToken ct)
+ {
+ var s = settings();
+ var all = await repo.ListAllAsync(includeArchived: false, ct);
+ var receipts = await audit.ListReceiptsAsync(nowUtc.AddDays(-7), nowUtc.AddMinutes(1), 20_000, ct);
+ var findings = Detect(all, receipts, nowUtc, s);
+
+ await KeepAsync(findings, nowUtc, ct);
+
+ var byId = all.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase);
+ var acted = 0;
+ foreach (var record in await PendingAsync(ct))
+ {
+ if (acted >= Math.Max(1, s.MaxActionsPerPass))
+ break; // the rest waits for the next pass
+ if (!byId.TryGetValue(record.TaskId, out var task))
+ continue;
+ var action = await ActAsync(record, task, nowUtc, ct);
+ await MarkActedAsync(record.Id, action, nowUtc, ct);
+ await audit.AppendAsync(new AuditEntry
+ {
+ RunId = "",
+ AgentId = TaskAssignee.AgentId(task.Assignee),
+ Source = "followup",
+ Tool = "TaskFollowUp",
+ Arguments = JsonSerializer.Serialize(new { kind = record.Kind, task = task.Id }),
+ Status = AuditStatus.Ok,
+ Summary = $"{record.Kind}: {action}",
+ OccurredAt = nowUtc
+ }, ct);
+ acted++;
+ }
+ if (acted > 0)
+ logger.LogInformation("Follow-up on tasks: {Count} action(s)", acted);
+ return acted;
+ }
+
+ // ─── Recognising ───
+
+ /// The patterns, from the board and the receipts of the last seven days. Pure, so the rules can be tested alone.
+ public static IReadOnlyList Detect(
+ IReadOnlyList all, IReadOnlyList receipts, DateTime nowUtc, FollowUpSettings s)
+ {
+ var byId = all.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase);
+ var result = new List();
+ string Mark(TaskItem t) => "u" + t.UpdatedAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
+
+ foreach (var t in all)
+ {
+ var kind = TaskAssignee.KindOf(t.Assignee);
+ var since = t.UpdatedAt.ToUniversalTime();
+ switch (t.Status)
+ {
+ case TaskItemStatus.InProgress when kind != TaskAssigneeKind.Human
+ && since < nowUtc.AddHours(-s.StalledHours)
+ && (t.ClaimedAt is null || t.ClaimedAt.Value.ToUniversalTime() < nowUtc.AddHours(-s.StalledHours)):
+ result.Add(new(FollowUpKind.Stalled, t.Id, t.Assignee, t.Title, since, Mark(t),
+ $"in progress without any change since {since:yyyy-MM-dd HH:mm} UTC"));
+ break;
+
+ case TaskItemStatus.Blocked when t.BlockedBy.Count > 0
+ && t.BlockedBy.All(b => byId.TryGetValue(b, out var blocker)
+ && blocker.Status is TaskItemStatus.Done or TaskItemStatus.Canceled or TaskItemStatus.Archived):
+ result.Add(new(FollowUpKind.Unblocked, t.Id, t.Assignee, t.Title, since, Mark(t),
+ $"blocked by {string.Join(", ", t.BlockedBy)} — all of them are finished"));
+ break;
+
+ case TaskItemStatus.Todo when kind == TaskAssigneeKind.Human && t.Title != DigestTitle
+ && since < nowUtc.AddHours(-s.HumanHours):
+ result.Add(new(FollowUpKind.HumanWaiting, t.Id, t.Assignee, t.Title, since, Mark(t),
+ $"waits for a human since {since:yyyy-MM-dd HH:mm} UTC"));
+ break;
+
+ case TaskItemStatus.InReview when since < nowUtc.AddHours(-s.ReviewHours):
+ result.Add(new(FollowUpKind.ReviewWaiting, t.Id, t.Assignee, t.Title, since, Mark(t),
+ $"waits for review since {since:yyyy-MM-dd HH:mm} UTC"));
+ break;
+ }
+ }
+
+ // A task that keeps ending at the step limit is not left lying but sized too tightly.
+ var week = ISOWeek.GetYear(nowUtc) + "-w" + ISOWeek.GetWeekOfYear(nowUtc).ToString("00", CultureInfo.InvariantCulture);
+ foreach (var group in receipts
+ .Where(r => !string.IsNullOrWhiteSpace(r.TaskId) && r.Status == "LoopLimitExceeded")
+ .GroupBy(r => r.TaskId!, StringComparer.OrdinalIgnoreCase)
+ .Where(g => g.Count() >= Math.Max(2, s.StepLimitRuns)))
+ {
+ if (!byId.TryGetValue(group.Key, out var t) || t.Status is TaskItemStatus.Done or TaskItemStatus.Canceled or TaskItemStatus.Archived)
+ continue;
+ result.Add(new(FollowUpKind.StepLimit, t.Id, t.Assignee, t.Title, group.Min(r => r.OccurredAt).ToUniversalTime(), week,
+ $"ended at the step limit {group.Count()} times in seven days"));
+ }
+ return result;
+ }
+
+ // ─── Keeping ───
+
+ private Task KeepAsync(IReadOnlyList findings, DateTime nowUtc, CancellationToken ct)
+ => storage.WriteAsync(async conn =>
+ {
+ var seen = new HashSet();
+ foreach (var f in findings)
+ {
+ using var upsert = conn.CreateCommand();
+ upsert.CommandText = """
+ INSERT INTO TaskFindings (Kind, TaskId, Mark, Assignee, Title, Detail, SinceAt, FirstSeenAt, LastSeenAt, Action)
+ VALUES (@kind, @task, @mark, @assignee, @title, @detail, @since, @now, @now, '')
+ ON CONFLICT (Kind, TaskId, Mark) DO UPDATE SET LastSeenAt = @now, Detail = @detail
+ RETURNING Id
+ """;
+ upsert.Parameters.AddWithValue("@kind", f.Kind);
+ upsert.Parameters.AddWithValue("@task", f.TaskId);
+ upsert.Parameters.AddWithValue("@mark", f.Mark);
+ upsert.Parameters.AddWithValue("@assignee", f.Assignee);
+ upsert.Parameters.AddWithValue("@title", f.Title);
+ upsert.Parameters.AddWithValue("@detail", f.Detail);
+ upsert.Parameters.AddWithValue("@since", Format(f.SinceUtc));
+ upsert.Parameters.AddWithValue("@now", Format(nowUtc));
+ seen.Add(Convert.ToInt64(await upsert.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture));
+ }
+
+ // What was found before and is not found now has resolved.
+ var open = new List();
+ using (var list = conn.CreateCommand())
+ {
+ list.CommandText = "SELECT Id FROM TaskFindings WHERE ResolvedAt IS NULL";
+ await using var r = await list.ExecuteReaderAsync(ct);
+ while (await r.ReadAsync(ct))
+ open.Add(r.GetInt64(0));
+ }
+ foreach (var id in open.Where(id => !seen.Contains(id)))
+ {
+ using var resolve = conn.CreateCommand();
+ resolve.CommandText = "UPDATE TaskFindings SET ResolvedAt = @now WHERE Id = @id";
+ resolve.Parameters.AddWithValue("@now", Format(nowUtc));
+ resolve.Parameters.AddWithValue("@id", id);
+ await resolve.ExecuteNonQueryAsync(ct);
+ }
+ return 0;
+ }, ct);
+
+ private async Task> PendingAsync(CancellationToken ct)
+ => await ListAsync(openOnly: true, 500, ct, pendingOnly: true);
+
+ /// Findings, newest first — open ones, or all.
+ public async Task> ListAsync(bool openOnly, int limit, CancellationToken ct, bool pendingOnly = false)
+ {
+ await using var conn = await storage.OpenConnectionAsync(ct);
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = $"""
+ SELECT Id, Kind, TaskId, Assignee, Title, Detail, FirstSeenAt, LastSeenAt, ActedAt, Action, ResolvedAt
+ FROM TaskFindings
+ WHERE 1 = 1 {(openOnly ? "AND ResolvedAt IS NULL" : "")} {(pendingOnly ? "AND ActedAt IS NULL" : "")}
+ ORDER BY {(pendingOnly ? "Id" : "Id DESC")}
+ LIMIT @limit
+ """;
+ cmd.Parameters.AddWithValue("@limit", Math.Clamp(limit, 1, 1000));
+ var result = new List();
+ await using var r = await cmd.ExecuteReaderAsync(ct);
+ while (await r.ReadAsync(ct))
+ result.Add(new TaskFindingRecord(r.GetInt64(0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetString(4), r.GetString(5),
+ Parse(r.GetString(6)), Parse(r.GetString(7)), r.IsDBNull(8) ? null : Parse(r.GetString(8)), r.GetString(9),
+ r.IsDBNull(10) ? null : Parse(r.GetString(10))));
+ return result;
+ }
+
+ private Task MarkActedAsync(long id, string action, DateTime nowUtc, CancellationToken ct)
+ => storage.WriteAsync(async conn =>
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "UPDATE TaskFindings SET ActedAt = @now, Action = @action WHERE Id = @id";
+ cmd.Parameters.AddWithValue("@now", Format(nowUtc));
+ cmd.Parameters.AddWithValue("@action", action);
+ cmd.Parameters.AddWithValue("@id", id);
+ await cmd.ExecuteNonQueryAsync(ct);
+ }, ct);
+
+ private async Task WokenRecentlyAsync(string taskId, DateTime nowUtc, CancellationToken ct)
+ {
+ await using var conn = await storage.OpenConnectionAsync(ct);
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ SELECT COUNT(*) FROM TaskFindings
+ WHERE TaskId = @task AND Kind = @kind AND Action = 'woken' AND ActedAt >= @since
+ """;
+ cmd.Parameters.AddWithValue("@task", taskId);
+ cmd.Parameters.AddWithValue("@kind", FollowUpKind.Stalled);
+ cmd.Parameters.AddWithValue("@since", Format(nowUtc.AddDays(-7)));
+ return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
+ }
+
+ // ─── Acting ───
+
+ private async Task ActAsync(TaskFindingRecord f, TaskItem task, DateTime nowUtc, CancellationToken ct)
+ {
+ switch (f.Kind)
+ {
+ case FollowUpKind.Stalled when await WokenRecentlyAsync(task.Id, nowUtc, ct) == 0:
+ await board.AddCommentAsync(task.Id, Author,
+ $"Follow-up: this task is {f.Detail}. It is started again once — pick up where it stands, or set it to blocked or canceled with the reason.", ct);
+ await board.UpdateAsync(task.Id, t => t with { Status = TaskItemStatus.Todo }, ct);
+ return "woken";
+
+ case FollowUpKind.Stalled:
+ await ToReviewerAsync(task, $"Follow-up: {task.Id} stalls again",
+ $"The task {task.Id} \"{task.Title}\" ({task.Assignee}) is {f.Detail} — although it was started again within the last seven days. Look at why it does not move on: unclear task, missing access, too large? Send it back with a comment, reassign, split or cancel it. Do not do the work yourself.", ct);
+ return "to reviewer";
+
+ case FollowUpKind.Unblocked:
+ await board.AddCommentAsync(task.Id, Author,
+ $"Follow-up: this task was {f.Detail}. It is open again.", ct);
+ await board.UpdateAsync(task.Id, t => t with { Status = TaskItemStatus.Todo, BlockedBy = [] }, ct);
+ return "unblocked";
+
+ case FollowUpKind.ReviewWaiting:
+ await ToReviewerAsync(task, $"Follow-up: {task.Id} waits for review",
+ $"The task {task.Id} \"{task.Title}\" ({task.Assignee}) {f.Detail}. Review it against its acceptance: accept it (done) or send it back with a comment.", ct);
+ return "to reviewer";
+
+ case FollowUpKind.StepLimit:
+ await ToReviewerAsync(task, $"Follow-up: {task.Id} keeps ending at the step limit",
+ $"The task {task.Id} \"{task.Title}\" ({task.Assignee}) {f.Detail}. It is sized too tightly or worded too openly. Look at its runs and decide: sharper wording, fewer files per run, a split into smaller tasks — or more steps for its agent, which only a human can grant.", ct);
+ return "to reviewer";
+
+ case FollowUpKind.HumanWaiting:
+ await DigestAsync($"- {task.Id} \"{task.Title}\" {f.Detail}", ct);
+ return "in digest";
+
+ default:
+ return "none";
+ }
+ }
+
+ /// A follow-up task beside the task, for the lead of its unit — or a human, where there is none.
+ private async Task ToReviewerAsync(TaskItem task, string title, string body, CancellationToken ct)
+ => await board.CreateAsync(new TaskItem
+ {
+ Title = title,
+ Body = body,
+ Assignee = TaskTreeWatch.ReviewerOf(task, organisation()),
+ Priority = Math.Clamp(task.Priority, 1, 5),
+ Status = TaskItemStatus.Todo,
+ Type = TaskItemType.Work,
+ CreatedBy = Author,
+ Acceptance = "The result says what was done with the task, or why it may stay as it is."
+ }, ct);
+
+ /// Everything that waits for a human, collected in one open task — not one notice each.
+ private async Task DigestAsync(string line, CancellationToken ct)
+ {
+ var open = (await board.ListAsync(new TaskQuery { Assignee = TaskAssignee.Human, Status = TaskItemStatus.Todo, Limit = 500 }, null, ct))
+ .FirstOrDefault(t => t.Title == DigestTitle);
+ open ??= await board.CreateAsync(new TaskItem
+ {
+ Title = DigestTitle,
+ Body = "Tasks for you that have waited longer than set in the follow-up settings. Done with this list: set it to done — a new one starts with the next finding.",
+ Assignee = TaskAssignee.Human,
+ Status = TaskItemStatus.Todo,
+ Type = TaskItemType.Work,
+ CreatedBy = Author
+ }, ct);
+ await board.AddCommentAsync(open.Id, Author, line, ct);
+ }
+
+ private static string Format(DateTime value) => value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
+
+ private static DateTime Parse(string value)
+ => DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
+}
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
index a4b694e..37c7670 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json
@@ -1298,5 +1298,19 @@
"tasks.participants": "Besprechungsteilnehmer",
"tasks.participantsPlaceholder": "agent-a, agent-b — leer: keine Besprechung",
"tasks.participantsHint": "Mit Teilnehmern ist die Aufgabe eine Besprechung: Jeder spricht einmal, in dieser Reihenfolge, mit Tagesordnung (der Aufgabentext) und dem bisher Gesagten; der zugewiesene Agent leitet, schreibt das Ergebnis und legt die Aufgaben dafür an. Das Protokoll landet im Bereich seiner Einheit.",
- "tasks.meetingWith": "Besprechung mit {participants}"
+ "tasks.meetingWith": "Besprechung mit {participants}",
+ "tasks.findings.title": "Liegengeblieben ({n})",
+ "tasks.findings.hint": "Vom Board alle 30 Minuten gefunden, nicht von einem Modell. Eine stockende Agentenaufgabe wird einmal neu gestartet; stockt sie wieder, erfährt es die Leitung ihrer Einheit. Was auf dich wartet, wird in einer Aufgabe gesammelt. Die Schwellen stehen in den Instanzeinstellungen (followUp).",
+ "tasks.findings.pending": "Noch nicht nachgefasst — höchstens zehn Nachfassungen je Durchgang.",
+ "tasks.findings.acted": "{action} — {time}",
+ "tasks.findings.kind.stalled": "kein Fortschritt",
+ "tasks.findings.kind.unblocked": "Blocker erledigt",
+ "tasks.findings.kind.human_waiting": "wartet auf einen Menschen",
+ "tasks.findings.kind.review_waiting": "wartet auf Abnahme",
+ "tasks.findings.kind.step_limit": "endet an der Schrittgrenze",
+ "tasks.findings.action.woken": "neu gestartet",
+ "tasks.findings.action.unblocked": "wieder geöffnet",
+ "tasks.findings.action.to_reviewer": "an Leitung oder Mensch gegeben",
+ "tasks.findings.action.in_digest": "für dich gesammelt",
+ "tasks.findings.action.none": "nichts zu tun"
}
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
index 28e4eb1..04ef04e 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json
@@ -1298,5 +1298,19 @@
"tasks.participants": "Meeting participants",
"tasks.participantsPlaceholder": "agent-a, agent-b — empty: not a meeting",
"tasks.participantsHint": "With participants the task is a meeting: each speaks once, in this order, with the agenda (the task message) and what was said before; the assigned agent chairs, writes the result and creates the tasks for it. Minutes go to the chair's unit workspace.",
- "tasks.meetingWith": "Meeting with {participants}"
+ "tasks.meetingWith": "Meeting with {participants}",
+ "tasks.findings.title": "Left lying ({n})",
+ "tasks.findings.hint": "Found by the board every 30 minutes, not by a model. A stalled agent task is started again once; if it stalls again, its unit's lead hears of it. Tasks waiting for you are collected in one task. The thresholds are in the instance settings (followUp).",
+ "tasks.findings.pending": "Not followed up yet — at most ten follow-ups per pass.",
+ "tasks.findings.acted": "{action} — {time}",
+ "tasks.findings.kind.stalled": "no progress",
+ "tasks.findings.kind.unblocked": "blockers finished",
+ "tasks.findings.kind.human_waiting": "waits for a human",
+ "tasks.findings.kind.review_waiting": "waits for review",
+ "tasks.findings.kind.step_limit": "ends at the step limit",
+ "tasks.findings.action.woken": "started again",
+ "tasks.findings.action.unblocked": "opened again",
+ "tasks.findings.action.to_reviewer": "handed to the lead or a human",
+ "tasks.findings.action.in_digest": "listed for you",
+ "tasks.findings.action.none": "nothing to do"
}
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js
index a1f8cf9..4630c65 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js
@@ -134,6 +134,10 @@ async function ladeTaskboard() {
const bretter = brettAuswahl(alle);
if (bretter) container.append(bretter);
+ // #646: what the board found left lying — shown above the tasks, never folded away.
+ const liegen = await erstelleBefundKarte();
+ if (liegen) container.append(liegen);
+
if (tasks.length === 0) {
container.append(
erstelleKarte(t("tasks.empty"), [
@@ -517,3 +521,31 @@ async function oeffneAufgabenDialog(task) {
modal.oeffnen();
}
+
+/**
+ * #646: Work that lives but does not move — found by the board, not by a model: stalled,
+ * unblocked, waiting for a human or a review, ending at the step limit. Each with what was done.
+ */
+async function erstelleBefundKarte() {
+ let befunde;
+ try {
+ befunde = (await api("/tasks/findings")) || [];
+ } catch {
+ return null;
+ }
+ if (befunde.length === 0) return null;
+
+ const zeilen = befunde.map((b) => erstelleElement("div", { klasse: "werkzeug-zeile" },
+ erstelleElement("div", { klasse: "werkzeug-info" },
+ erstelleElement("strong", { text: `${b.taskId} · ${t(`tasks.findings.kind.${b.kind}`)}` }),
+ erstelleElement("div", { text: b.title }),
+ erstelleElement("div", { klasse: "werkzeug-beschreibung gedaempft", text: `${b.detail} · ${b.assignee}` }),
+ erstelleElement("div", { klasse: "werkzeug-beschreibung text-klein", text: b.actedAt
+ ? t("tasks.findings.acted", { action: t(`tasks.findings.action.${b.action.replace(" ", "_")}`), time: formatiereDatumZeit(b.actedAt) })
+ : t("tasks.findings.pending") }))));
+
+ return erstelleKarte(t("tasks.findings.title", { n: befunde.length }), [
+ erstelleElement("p", { klasse: "gedaempft text-klein", text: t("tasks.findings.hint") }),
+ ...zeilen
+ ]);
+}
diff --git a/src/ClawdDotNet.Web/Contracts/TaskFindingDtos.cs b/src/ClawdDotNet.Web/Contracts/TaskFindingDtos.cs
new file mode 100644
index 0000000..7a609ef
--- /dev/null
+++ b/src/ClawdDotNet.Web/Contracts/TaskFindingDtos.cs
@@ -0,0 +1,6 @@
+namespace ClawdDotNet.Web.Contracts;
+
+/// #646: A task found left lying, and what the board did about it.
+public sealed record TaskFindingDto(
+ long Id, string Kind, string TaskId, string Assignee, string Title, string Detail,
+ DateTime FirstSeen, DateTime LastSeen, DateTime? ActedAt, string Action, DateTime? ResolvedAt);
diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
index 917b257..41c3f5c 100644
--- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
+++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
@@ -289,6 +289,13 @@ internal static class ApiEndpoints
{
var tasks = api.MapGroup("/tasks");
+ // #646: what the board found left lying, and what it did about it.
+ tasks.MapGet("/findings", async (AppHost host, CancellationToken ct, bool all = false, int limit = 100) =>
+ host.FollowUp is { } followUp
+ ? Results.Ok((await followUp.ListAsync(!all, limit, ct)).Select(f => new TaskFindingDto(
+ f.Id, f.Kind, f.TaskId, f.Assignee, f.Title, f.Detail, f.FirstSeenUtc, f.LastSeenUtc, f.ActedAtUtc, f.Action, f.ResolvedAtUtc)).ToList())
+ : Results.Ok(Array.Empty()));
+
// #594: Das Taskboard ist die einzige Stelle fuer geplante Arbeit. Die frueheren
// /api/jobs bearbeiteten die Alt-Konfiguration, die nach der Uebernahme nichts mehr bewirkte.
tasks.MapGet("/", async (AppHost host, CancellationToken ct, int limit = 50) =>
diff --git a/tests/ClawdDotNet.Core.Tests/Storage/AtomicFileTests.cs b/tests/ClawdDotNet.Core.Tests/Storage/AtomicFileTests.cs
index 349cbe4..8a5fc7d 100644
--- a/tests/ClawdDotNet.Core.Tests/Storage/AtomicFileTests.cs
+++ b/tests/ClawdDotNet.Core.Tests/Storage/AtomicFileTests.cs
@@ -119,6 +119,9 @@ public sealed class AtomicFileTests : IDisposable
AtomicFile.WriteAllText(path, klein);
+ var fehler = 0;
+ var gelesen = 0;
+
// Harte Zeitgrenze, damit ein Fehler im Schreiber den Test nicht hängen lässt.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
@@ -126,7 +129,9 @@ public sealed class AtomicFileTests : IDisposable
{
try
{
- for (var i = 0; i < 40; i++)
+ // #722: Geschrieben wird, bis der Leser genug gesehen hat — nicht eine feste
+ // Zahl von Malen. Unter Parallellast kam der Leser sonst nur auf fuenf Blicke.
+ for (var i = 0; (i < 40 || Volatile.Read(ref gelesen) < 20) && !cts.IsCancellationRequested; i++)
{
AtomicFile.WriteAllText(path, i % 2 == 0 ? gross : klein);
await Task.Delay(1);
@@ -138,9 +143,6 @@ public sealed class AtomicFileTests : IDisposable
}
});
- var fehler = 0;
- var gelesen = 0;
-
var leser = Task.Run(async () =>
{
while (!cts.IsCancellationRequested)
@@ -150,7 +152,7 @@ public sealed class AtomicFileTests : IDisposable
// AtomicFile.ReadAllText, damit der Leser den Schreiber nicht
// blockiert — genau dafür gibt es den Helfer.
var inhalt = AtomicFile.ReadAllText(path);
- gelesen++;
+ Interlocked.Increment(ref gelesen);
// Muss immer ein vollständiges JSON-Array sein.
JsonSerializer.Deserialize(inhalt);
diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/TaskFollowUpTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/TaskFollowUpTests.cs
new file mode 100644
index 0000000..8447433
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Tasks/TaskFollowUpTests.cs
@@ -0,0 +1,131 @@
+using ClawdDotNet.Core.Audit;
+using ClawdDotNet.Core.Storage;
+using ClawdDotNet.Core.Tasks;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Tasks;
+
+///
+/// #646: The board follows up on work that lives but does not move — once per state, braked,
+/// and with what it found kept for supervision.
+///
+public sealed class TaskFollowUpTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "fu-" + Guid.NewGuid().ToString("N")[..8]);
+ private readonly SqliteTaskRepository _repo;
+ private readonly TaskboardService _board;
+ private readonly SqliteAuditRepository _audit;
+ private readonly FollowUpSettings _settings = new();
+ private readonly TaskFollowUp _followUp;
+
+ public TaskFollowUpTests()
+ {
+ var storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
+ _repo = new SqliteTaskRepository(storage);
+ _board = new TaskboardService(_repo, Path.Combine(_directory, "tasks"));
+ _audit = new SqliteAuditRepository(storage);
+ _followUp = new TaskFollowUp(_board, _repo, _audit, storage, () => _settings, () => null, NullLogger.Instance);
+ }
+
+ public void Dispose()
+ {
+ SqliteStorage.ReleasePooledConnections(_directory);
+ try { Directory.Delete(_directory, recursive: true); }
+ catch { /* cleanup is secondary */ }
+ }
+
+ private static DateTime Later(double hours) => DateTime.UtcNow.AddHours(hours);
+
+ private async Task Task(string title, string assignee, TaskItemStatus status, IReadOnlyList? blockedBy = null)
+ {
+ var task = await _board.CreateAsync(new TaskItem { Title = title, Assignee = assignee, BlockedBy = blockedBy ?? [] }, default);
+ if (status != TaskItemStatus.Todo)
+ task = (await _board.UpdateAsync(task.Id, t => t with { Status = status }, default))!;
+ return task;
+ }
+
+ private async Task> All() => await _repo.ListAllAsync(includeArchived: false, default);
+
+ [Fact]
+ public async Task A_stalled_agent_task_is_woken_once_and_its_lead_hears_when_it_stalls_again()
+ {
+ var task = await Task("Write the report", "@desk", TaskItemStatus.InProgress);
+
+ (await _followUp.PassAsync(Later(1), default)).ShouldBe(0, "not stalled yet");
+ (await _followUp.PassAsync(Later(25), default)).ShouldBe(1);
+
+ var woken = (await _repo.GetAsync(task.Id, default))!;
+ woken.Status.ShouldBe(TaskItemStatus.Todo);
+ woken.Body.ShouldContain("Follow-up: this task is in progress without any change");
+ (await _audit.ListForAgentAsync("desk", null, 10, default)).ShouldContain(e => e.Tool == "TaskFollowUp");
+
+ await _board.UpdateAsync(task.Id, t => t with { Status = TaskItemStatus.InProgress }, default);
+ (await _followUp.PassAsync(Later(50), default)).ShouldBe(1);
+
+ (await All()).ShouldContain(t => t.Title == $"Follow-up: {task.Id} stalls again" && t.Assignee == TaskAssignee.Human,
+ "without units, a human is the lead");
+ (await _repo.GetAsync(task.Id, default))!.Status.ShouldBe(TaskItemStatus.InProgress, "woken once, not again");
+ }
+
+ [Fact]
+ public async Task A_task_whose_blockers_are_finished_is_opened_again()
+ {
+ var blocker = await Task("Fetch the rates", "@kurse", TaskItemStatus.Done);
+ var blocked = await Task("Book the rates", "@desk", TaskItemStatus.Blocked, [blocker.Id]);
+ var stillBlocked = await Task("Wait for the other", "@desk", TaskItemStatus.Blocked, [blocker.Id, "t-missing"]);
+
+ (await _followUp.PassAsync(Later(0.1), default)).ShouldBe(1);
+
+ var opened = (await _repo.GetAsync(blocked.Id, default))!;
+ opened.Status.ShouldBe(TaskItemStatus.Todo);
+ opened.BlockedBy.ShouldBeEmpty();
+ (await _repo.GetAsync(stillBlocked.Id, default))!.Status.ShouldBe(TaskItemStatus.Blocked, "a blocker that is not there is not finished");
+ }
+
+ [Fact]
+ public async Task What_waits_for_a_human_is_collected_in_one_task_once()
+ {
+ await Task("Approve the budget", TaskAssignee.Human, TaskItemStatus.Todo);
+ await Task("Answer the supplier", TaskAssignee.Human, TaskItemStatus.Todo);
+
+ (await _followUp.PassAsync(Later(49), default)).ShouldBe(2);
+ (await _followUp.PassAsync(Later(50), default)).ShouldBe(0, "once per state");
+
+ var digest = (await All()).Single(t => t.Title == TaskFollowUp.DigestTitle);
+ digest.Body.ShouldContain("Approve the budget");
+ digest.Body.ShouldContain("Answer the supplier");
+ }
+
+ [Fact]
+ public async Task A_task_that_keeps_ending_at_the_step_limit_goes_to_the_lead_once_a_week()
+ {
+ var task = await Task("Digest all news", "@nachrichten", TaskItemStatus.Todo);
+ for (var i = 0; i < 3; i++)
+ await _audit.RecordReceiptAsync(new RunReceipt { RunId = $"r{i}", AgentId = "nachrichten", TaskId = task.Id, Status = "LoopLimitExceeded", OccurredAt = DateTime.UtcNow }, default);
+
+ (await _followUp.PassAsync(Later(0.1), default)).ShouldBe(1);
+ (await _followUp.PassAsync(Later(1), default)).ShouldBe(0);
+
+ (await All()).ShouldContain(t => t.Title == $"Follow-up: {task.Id} keeps ending at the step limit");
+ }
+
+ [Fact]
+ public async Task Findings_are_kept_braked_and_resolved_when_the_task_moves()
+ {
+ _settings.MaxActionsPerPass = 1;
+ var a = await Task("A", "@desk", TaskItemStatus.InReview);
+ await Task("B", "@desk", TaskItemStatus.InReview);
+
+ (await _followUp.PassAsync(Later(49), default)).ShouldBe(1, "braked");
+ (await _followUp.ListAsync(openOnly: true, 10, default)).Count.ShouldBe(2);
+ (await _followUp.PassAsync(Later(49.1), default)).ShouldBe(1, "the rest follows in the next pass");
+
+ await _board.UpdateAsync(a.Id, t => t with { Status = TaskItemStatus.Done }, default);
+ await _followUp.PassAsync(Later(49.2), default);
+
+ var open = await _followUp.ListAsync(openOnly: true, 10, default);
+ open.ShouldAllBe(f => f.TaskId != a.Id);
+ (await _followUp.ListAsync(openOnly: false, 10, default)).Single(f => f.TaskId == a.Id).ResolvedAtUtc.ShouldNotBeNull();
+ }
+}
diff --git a/tests/ClawdDotNet.Manager.Tests/Doubles.cs b/tests/ClawdDotNet.Manager.Tests/Doubles.cs
index f514947..4880cf8 100644
--- a/tests/ClawdDotNet.Manager.Tests/Doubles.cs
+++ b/tests/ClawdDotNet.Manager.Tests/Doubles.cs
@@ -172,9 +172,18 @@ internal sealed class FakeChannel : IInstanceChannel
: Answer);
}
+ ///
+ /// #722: What the instance does when the request arrives — typically: end. A test that
+ /// ended the process before asking raced the supervisor's own check: under load the check
+ /// saw the process gone first and called it a crash, not a stop.
+ ///
+ public Action? OnStopRequest { get; set; }
+
public Task RequestStopAsync(string instanceDirectory, CancellationToken ct = default)
{
StopRequests++;
+ if (StopRequestArrives)
+ OnStopRequest?.Invoke(instanceDirectory);
return Task.FromResult(StopRequestArrives);
}
diff --git a/tests/ClawdDotNet.Manager.Tests/InstanceRegistryTests.cs b/tests/ClawdDotNet.Manager.Tests/InstanceRegistryTests.cs
index 076ff3b..b550b79 100644
--- a/tests/ClawdDotNet.Manager.Tests/InstanceRegistryTests.cs
+++ b/tests/ClawdDotNet.Manager.Tests/InstanceRegistryTests.cs
@@ -139,8 +139,8 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
register.Instances.Single().Condition.ShouldBe(InstanceCondition.Running);
// Die Instanz endet auf die Bitte hin.
- _launcher.Last!.Exit(0);
- _kanal.Answer = null;
+ var prozess = _launcher.Last!;
+ _kanal.OnStopRequest = _ => { _kanal.Answer = null; prozess.Exit(0); };
(await register.StopAsync(pfad)).ShouldBeTrue();
register.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped);
@@ -230,8 +230,8 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
var a = register.Create("Alpha");
await register.StartAsync(a);
- _launcher.Last!.Exit(0);
- _kanal.Answer = null;
+ var prozess = _launcher.Last!;
+ _kanal.OnStopRequest = _ => { _kanal.Answer = null; prozess.Exit(0); };
await register.StopAllAsync("Lizenz widerrufen");
diff --git a/tests/ClawdDotNet.Manager.Tests/InstanceSupervisorTests.cs b/tests/ClawdDotNet.Manager.Tests/InstanceSupervisorTests.cs
index c2dca95..fb0e6f2 100644
--- a/tests/ClawdDotNet.Manager.Tests/InstanceSupervisorTests.cs
+++ b/tests/ClawdDotNet.Manager.Tests/InstanceSupervisorTests.cs
@@ -59,10 +59,10 @@ public sealed class InstanceSupervisorTests : IDisposable
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
- private InstanceSupervisor Aufsicht()
+ private InstanceSupervisor Aufsicht(SupervisionOptions? options = null)
{
var supervisor = new InstanceSupervisor(
- _dir, _launcher, _kanal, NullLogger.Instance, Options);
+ _dir, _launcher, _kanal, NullLogger.Instance, options ?? Options);
supervisor.Observed += _ereignisse.Add;
return supervisor;
@@ -307,20 +307,24 @@ public sealed class InstanceSupervisorTests : IDisposable
{
_kanal.Answer = Test;
- await using var aufsicht = Aufsicht();
+ // #722: Die Aufsicht fragt alle 250 ms und schiebt die Frist nur bei "raeume auf" weiter.
+ // Mit 300 ms Frist kam unter Parallellast die naechste Frage zu spaet, und der Test sah
+ // einen Abschuss, wo keiner hingehoert. Eine Sekunde Frist laesst dem Takt Luft; die
+ // Instanz raeumt trotzdem deutlich laenger auf, als die Frist allein erlaubt.
+ await using var aufsicht = Aufsicht(Options with { StopGrace = TimeSpan.FromSeconds(1), MaxDrainWait = TimeSpan.FromSeconds(20) });
await aufsicht.StartAsync();
var prozess = _launcher.Last.ShouldNotBeNull();
- // Sie antwortet und meldet: Ich raeume auf. Laenger als StopGrace (300ms), aber
- // kuerzer als MaxDrainWait (2s).
+ // Sie antwortet und meldet: Ich raeume auf. Laenger als StopGrace (1 s), aber
+ // kuerzer als MaxDrainWait.
_kanal.Answer = Test with { Phase = ShutdownPhase.Draining, OpenWork = 1 };
- _ = Task.Run(async () =>
+ _kanal.OnStopRequest = _ => Task.Run(async () =>
{
- await Task.Delay(700);
- _kanal.Answer = null;
+ await Task.Delay(2500);
prozess.Exit(0);
+ _kanal.Answer = null;
});
(await aufsicht.StopAsync()).ShouldBeTrue();
diff --git a/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs b/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs
index ad22252..24c3356 100644
--- a/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs
+++ b/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs
@@ -182,8 +182,8 @@ public sealed class ManagerCoreTests : IAsyncDisposable
manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running);
// Die Instanz endet auf die Bitte hin.
- _launcher.Last!.Exit(0);
- _kanal.AnswerFor[Path.GetFullPath(pfad)] = null;
+ var prozess = _launcher.Last!;
+ _kanal.OnStopRequest = _ => { _kanal.AnswerFor[Path.GetFullPath(pfad)] = null; prozess.Exit(0); };
await manager.HandleLicenseRevokedAsync("Widerrufen durch den Hersteller");