Files
ClawdDotNet/src/ClawdDotNet.Core/Tasks/TaskboardService.cs
T
RichardandClaude Opus 5.5 9cf9a2cfc2 Budgetende, Stufe 3: Nachholen nur im Spielraum des Tages, keine Kappung bei 500 (#716)
- TaskSchedule.OccurrencesBetween zaehlt, wie oft eine Aufgabe bis zu einem
  Zeitpunkt noch laeuft, einschliesslich eines jetzt faelligen Laufs.
- BudgetHeadroom (ILateRunGate): Ein mitgenommener oder nachzuholender Lauf
  startet nur, wenn er in den Spielraum passt: Grenze minus Verbrauch minus
  Reservierung minus Bedarf der restlichen geplanten Laeufe bis Mitternacht
  minus 10 % Reserve. Das gilt je Agent und je Instanz, fuer Kosten und Tokens.
  Anderes Spaetwerk zaehlt nicht als Bedarf, sonst blockierten sich
  mitgenommene Aufgaben gegenseitig.
- Der Scanner fragt den Spielraum nur fuer Spaetwerk und protokolliert, wenn es
  warten muss. Die Engine gibt ihren BudgetGuard heraus, damit Scanner und
  Engine dieselben Reservierungen sehen.
- Ersetzt den pauschalen 25-%-Anteil aus dem Konzept (dort nachgezogen): Ein
  fester Anteil ist an vollen Tagen zu viel, an ruhigen zu wenig.

Drei weitere stille Kappungen bei 500 beseitigt (ListAllAsync, ungekappt):
Kostenprognose (Aufgaben ueber 500 fehlten), Waisenabgleich beim Import und
task_find aus #674, wo Dubletten unter aelteren Aufgaben mit niedriger
Prioritaet unsichtbar wurden.

Tests: BudgetHeadroomTests (9), Scanner-Pforte (1), Dublette hinter 500
Aufgaben (1). 1.257 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:10:40 +02:00

191 lines
7.1 KiB
C#

using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Core.Tasks;
/// <summary>
/// Die Brücke der Wahrheitsaufteilung: Markdown-Dateien unter <c>SharedWorkspace/tasks/</c>
/// halten die Definition, die DB (<see cref="ITaskRepository"/>) den Ausführungszustand.
/// Dieser Dienst hält beide im Gleichschritt und wird von drei Seiten genutzt — dem
/// Agenten-Tool, dem Scanner und dem Start (Reconciliation).
///
/// Bewusst zustandslos über den Aufrufen: nur Repository und Verzeichnis, keine
/// gepufferten Aufgaben. So kann jede Seite ihn nach Bedarf erzeugen.
/// </summary>
public sealed class TaskboardService
{
private readonly ITaskRepository _repo;
private readonly string _tasksDir;
public TaskboardService(ITaskRepository repo, string tasksDirectory)
{
_repo = repo;
_tasksDir = tasksDirectory;
}
public string TasksDirectory => _tasksDir;
// ─── Lesen (direkt aus der DB) ───
public Task<TaskItem?> GetAsync(string id, CancellationToken ct) => _repo.GetAsync(id, ct);
public Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct)
=> _repo.ListAsync(query, ct);
/// <summary>The whole board, without the cap of <see cref="ListAsync"/>.</summary>
public Task<IReadOnlyList<TaskItem>> ListAllAsync(CancellationToken ct, bool includeArchived = false)
=> _repo.ListAllAsync(includeArchived, ct);
// ─── Anlegen ───
/// <summary>
/// Legt eine Aufgabe an: vergibt (falls nötig) eine stabile Id, schreibt die Datei und
/// spiegelt sie in die DB. Datei und DB entstehen in einem Zug.
/// </summary>
public async Task<TaskItem> CreateAsync(TaskItem definition, CancellationToken ct)
{
var id = string.IsNullOrWhiteSpace(definition.Id) ? await NewIdAsync(ct) : definition.Id.Trim();
var fileName = string.IsNullOrWhiteSpace(definition.FileName)
? BuildFileName(definition.Title, id)
: definition.FileName;
var toWrite = definition with { Id = id, FileName = fileName };
WriteFile(toWrite);
return await _repo.UpsertAsync(toWrite, ct);
}
// ─── Ändern ───
/// <summary>
/// Ändert die Definition einer Aufgabe (Tool-Aktion). Schreibt Datei und DB. Eine
/// Status-Änderung wird zusätzlich explizit gesetzt — der Import spiegelt nur die
/// Definition und lässt den Ausführungszustand absichtlich unangetastet, damit ein
/// blinder Re-Import einen laufenden Zustand nicht zurücksetzt.
/// </summary>
public async Task<TaskItem?> UpdateAsync(
string id, Func<TaskItem, TaskItem> mutate, CancellationToken ct)
{
var current = await _repo.GetAsync(id, ct);
if (current is null) return null;
var updated = mutate(current) with { Id = id, FileName = current.FileName };
WriteFile(updated);
await _repo.UpsertAsync(updated, ct);
if (updated.Status != current.Status)
await _repo.SetStatusAsync(id, updated.Status, DateTime.UtcNow, ct);
return await _repo.GetAsync(id, ct);
}
/// <summary>Hängt einen Kommentar (Ergebnis, Kritik) an den Rumpf an.</summary>
public Task<TaskItem?> AddCommentAsync(string id, string author, string text, CancellationToken ct)
=> UpdateAsync(id, current =>
{
var stamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm");
var block = $"---\n**{author}** ({stamp}):\n\n{text.Trim()}";
var body = string.IsNullOrWhiteSpace(current.Body) ? block : $"{current.Body.TrimEnd()}\n\n{block}";
return current with { Body = body };
}, ct);
// ─── Import (Datei → DB) ───
/// <summary>
/// Liest alle Task-Dateien und spiegelt sie in die DB. DB-Zeilen, deren Datei
/// verschwunden ist, werden archiviert (nicht gelöscht — die Historie bleibt). Gibt
/// die Zahl der importierten Dateien zurück.
/// </summary>
public async Task<int> ImportAllAsync(CancellationToken ct)
{
if (!Directory.Exists(_tasksDir))
return 0;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var imported = 0;
foreach (var path in Directory.EnumerateFiles(_tasksDir, "*.md"))
{
var task = await ImportFileAsync(path, ct);
if (task is not null) { seen.Add(task.Id); imported++; }
}
// Verwaiste Zeilen archivieren. Ungekappt (#716): Vorher sah dieser Abgleich nur die
// ersten 500 Zeilen, und darüber blieben verwaiste Zeilen still stehen.
var known = await _repo.ListAllAsync(includeArchived: true, ct);
foreach (var task in known)
if (task.Status != TaskItemStatus.Archived && !seen.Contains(task.Id))
await _repo.SetStatusAsync(task.Id, TaskItemStatus.Archived, DateTime.UtcNow, ct);
return imported;
}
/// <summary>Importiert eine einzelne Datei. Eine von Hand angelegte Datei ohne
/// <c>id</c> bekommt eine zugewiesen und wird einmalig kanonisch zurückgeschrieben.</summary>
public async Task<TaskItem?> ImportFileAsync(string path, CancellationToken ct)
{
string text;
try { text = AtomicFile.ReadAllText(path); }
catch { return null; }
if (!TaskFrontmatter.TryParse(text, out var definition, out _))
return null;
var fileName = Path.GetFileName(path);
if (string.IsNullOrWhiteSpace(definition.Id))
{
definition = definition with { Id = await NewIdAsync(ct), FileName = fileName };
WriteFile(definition); // Id festschreiben, damit sie den nächsten Start überlebt
}
else
{
definition = definition with { FileName = fileName };
}
return await _repo.UpsertAsync(definition, ct);
}
// ─── Hilfsfunktionen ───
private void WriteFile(TaskItem definition)
{
Directory.CreateDirectory(_tasksDir);
var path = Path.Combine(_tasksDir, definition.FileName);
AtomicFile.WriteAllText(path, TaskFrontmatter.Serialize(definition));
}
private async Task<string> NewIdAsync(CancellationToken ct)
{
for (var i = 0; i < 5; i++)
{
var id = "t-" + Guid.NewGuid().ToString("N")[..6];
if (await _repo.GetAsync(id, ct) is null)
return id;
}
return "t-" + Guid.NewGuid().ToString("N")[..12];
}
private static string BuildFileName(string title, string id)
{
var slug = Slug(title);
var suffix = id.StartsWith("t-", StringComparison.Ordinal) ? id[2..] : id;
return (slug.Length == 0 ? "task" : slug) + "-" + suffix + ".md";
}
private static string Slug(string title)
{
var chars = title.Trim().ToLowerInvariant()
.Select(c => char.IsLetterOrDigit(c) ? c : '-')
.ToArray();
var slug = new string(chars);
while (slug.Contains("--"))
slug = slug.Replace("--", "-");
slug = slug.Trim('-');
if (slug.Length > 40)
slug = slug[..40].Trim('-');
return slug;
}
}