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:
co-authored by
Claude Opus 5.5
parent
095722ae9c
commit
e42ec100b0
@@ -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.
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user