Taskboard fasst nach: stockende, entblockte und wartende Aufgaben erkennen und nachfassen (#646)

Alle 30 Minuten, als Abfrage statt Modelllauf: Agentenaufgabe in Arbeit ohne
Aenderung (Vorgabe 24 h) -> einmal neu gestartet, stockt sie binnen einer
Woche wieder, geht es an die Leitung bzw. den Menschen; blockiert mit
erledigten Blockern -> wieder offen; @human-Aufgabe ueber 48 h -> in einer
Sammelaufgabe fuer den Menschen; in_review ueber 48 h und wiederholtes
LoopLimitExceeded (3 in 7 Tagen) -> Aufgabe an die Leitung. Einmal je Zustand,
hoechstens 10 Nachfassungen je Durchgang, jede im Audit. Befunde liegen
abfragbar in TaskFindings (fuer #680); WebUI-Karte 'Liegengeblieben'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-02 11:49:04 +02:00
co-authored by Claude Opus 5.5
parent 095722ae9c
commit e42ec100b0
10 changed files with 617 additions and 3 deletions
+11 -1
View File
@@ -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; }
@@ -937,6 +940,11 @@ 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"));
host.FollowUp = 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,
@@ -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.
+376
View File
@@ -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) =>
@@ -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();
}
}