diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 2d4278a..89d03e4 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; } @@ -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, 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/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(); + } +}