Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -141,6 +141,9 @@ public sealed class AppHost : IAsyncDisposable
|
||||
/// <summary>#725: Inbound triggers — n8n or any other system hands an agent a task.</summary>
|
||||
public Core.Triggers.InboundTriggers? Triggers { get; private set; }
|
||||
|
||||
/// <summary>#646: Findings on work that lives but does not move.</summary>
|
||||
public Core.Tasks.TaskFollowUp? FollowUp { get; private set; }
|
||||
|
||||
/// <summary>#700: Day values and the month archive of audit and receipts.</summary>
|
||||
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)
|
||||
|
||||
@@ -155,6 +155,10 @@ public sealed class InstanceConfig
|
||||
[JsonPropertyName("claudeCode")]
|
||||
public Runtimes.ClaudeCodeSettings ClaudeCode { get; set; } = new();
|
||||
|
||||
/// <summary>#646: When the board follows up on work that lives but does not move.</summary>
|
||||
[JsonPropertyName("followUp")]
|
||||
public Tasks.FollowUpSettings FollowUp { get; set; } = new();
|
||||
|
||||
/// <summary>#700: How long runs stay in the working database before they move to the month archive — nothing is deleted.</summary>
|
||||
[JsonPropertyName("auditArchive")]
|
||||
public Audit.AuditArchiveSettings AuditArchive { get; set; } = new();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>#646: When work counts as left lying (<c>followUp</c> in the instance settings).</summary>
|
||||
public sealed class FollowUpSettings
|
||||
{
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>An agent's task in progress without any change for this long.</summary>
|
||||
[JsonPropertyName("stalledHours")]
|
||||
public int StalledHours { get; set; } = 24;
|
||||
|
||||
/// <summary>A task for a human that waits this long.</summary>
|
||||
[JsonPropertyName("humanHours")]
|
||||
public int HumanHours { get; set; } = 48;
|
||||
|
||||
/// <summary>A task in review that nobody takes up for this long.</summary>
|
||||
[JsonPropertyName("reviewHours")]
|
||||
public int ReviewHours { get; set; } = 48;
|
||||
|
||||
/// <summary>Runs of one task that end at the step limit within seven days.</summary>
|
||||
[JsonPropertyName("stepLimitRuns")]
|
||||
public int StepLimitRuns { get; set; } = 3;
|
||||
|
||||
/// <summary>Follow-ups per pass at most — waking an agent costs a run.</summary>
|
||||
[JsonPropertyName("maxActionsPerPass")]
|
||||
public int MaxActionsPerPass { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>The patterns of work that formally lives but does not move on.</summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>A finding of one pass: what, which task, since when. <see cref="Mark"/> is the state it was found in.</summary>
|
||||
public sealed record TaskFinding(string Kind, string TaskId, string Assignee, string Title, DateTime SinceUtc, string Mark, string Detail);
|
||||
|
||||
/// <summary>A finding as kept: when first and last seen, what was done, when it resolved.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// #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.
|
||||
///
|
||||
/// <para><b>This recognises, others interpret</b> (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.</para>
|
||||
///
|
||||
/// <para><b>Once per state, braked.</b> 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
|
||||
/// <see cref="FollowUpSettings.MaxActionsPerPass"/> actions per pass — waking costs runs.
|
||||
/// Not the claim lease: that clears crashed runs; this is about work that has not crashed.</para>
|
||||
/// </summary>
|
||||
public sealed class TaskFollowUp(
|
||||
TaskboardService board,
|
||||
ITaskRepository repo,
|
||||
IAuditRepository audit,
|
||||
SqliteStorage storage,
|
||||
Func<FollowUpSettings> settings,
|
||||
Func<OrgChart?> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One pass: find, keep, act on what is new, resolve what is gone. Returns the actions taken.</summary>
|
||||
public async Task<int> 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 ───
|
||||
|
||||
/// <summary>The patterns, from the board and the receipts of the last seven days. Pure, so the rules can be tested alone.</summary>
|
||||
public static IReadOnlyList<TaskFinding> Detect(
|
||||
IReadOnlyList<TaskItem> all, IReadOnlyList<RunReceipt> receipts, DateTime nowUtc, FollowUpSettings s)
|
||||
{
|
||||
var byId = all.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase);
|
||||
var result = new List<TaskFinding>();
|
||||
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<TaskFinding> findings, DateTime nowUtc, CancellationToken ct)
|
||||
=> storage.WriteAsync(async conn =>
|
||||
{
|
||||
var seen = new HashSet<long>();
|
||||
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<long>();
|
||||
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<IReadOnlyList<TaskFindingRecord>> PendingAsync(CancellationToken ct)
|
||||
=> await ListAsync(openOnly: true, 500, ct, pendingOnly: true);
|
||||
|
||||
/// <summary>Findings, newest first — open ones, or all.</summary>
|
||||
public async Task<IReadOnlyList<TaskFindingRecord>> 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<TaskFindingRecord>();
|
||||
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<int> 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<string> 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";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A follow-up task beside the task, for the lead of its unit — or a human, where there is none.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Everything that waits for a human, collected in one open task — not one notice each.</summary>
|
||||
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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ClawdDotNet.Web.Contracts;
|
||||
|
||||
/// <summary>#646: A task found left lying, and what the board did about it.</summary>
|
||||
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);
|
||||
@@ -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<TaskFindingDto>()));
|
||||
|
||||
// #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) =>
|
||||
|
||||
@@ -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<string[]>(inhalt);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// #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.
|
||||
/// </summary>
|
||||
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<TaskItem> Task(string title, string assignee, TaskItemStatus status, IReadOnlyList<string>? 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<IReadOnlyList<TaskItem>> 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();
|
||||
}
|
||||
}
|
||||
@@ -172,9 +172,18 @@ internal sealed class FakeChannel : IInstanceChannel
|
||||
: Answer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #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.
|
||||
/// </summary>
|
||||
public Action<string>? OnStopRequest { get; set; }
|
||||
|
||||
public Task<bool> RequestStopAsync(string instanceDirectory, CancellationToken ct = default)
|
||||
{
|
||||
StopRequests++;
|
||||
if (StopRequestArrives)
|
||||
OnStopRequest?.Invoke(instanceDirectory);
|
||||
return Task.FromResult(StopRequestArrives);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user