Grossaufgaben und Sonderbudget (#717, Stufe 2+3)
Eine Aufgabe, deren eigene Schaetzung (gemessen, Geschwister oder Autor) ueber der Haelfte der Tageskostengrenze liegt, bekommt zuerst einen Planungslauf: teilen (Teile unter der Elternaufgabe) oder Sonderbudget beantragen. Danach wartet sie auf ihre Teile bzw. die Freigabe und laeuft einmal zum Zusammenfuehren. Sonderbudget ueber Escalation.request_budget, ausnahmslos freigabepflichtig (auch gegen Werkzeugkonfiguration und Dauerfreigabe). Laeufe im Baum ziehen den Topf statt der Tagesgrenzen, tragen BudgetPot in Verbrauch und Beleg und sind in der Kostenanalyse (nach Budget) und auf der Aufgabenkarte gesondert sichtbar. Aufgebraucht zaehlen sie wieder gegen den Tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
183e922278
commit
67649559f5
@@ -53,7 +53,14 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
_clock = clock ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
|
||||
public Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
|
||||
=> DispatchAsync(task, null, ct);
|
||||
|
||||
/// <summary>
|
||||
/// #717: With an instruction from the code ahead of the task — for a large task, that the
|
||||
/// run plans instead of starting the work.
|
||||
/// </summary>
|
||||
public async Task<TaskLauf> DispatchAsync(TaskItem task, string? instruction, CancellationToken ct)
|
||||
{
|
||||
// Poll-Tasks (tool_job) ticken ein Tool statt einen Agenten direkt zu starten.
|
||||
if (task.Type == TaskItemType.ToolJob)
|
||||
@@ -61,6 +68,8 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
|
||||
var agents = _agents();
|
||||
var message = BuildMessage(task, await WhyAsync(task, ct));
|
||||
if (!string.IsNullOrWhiteSpace(instruction))
|
||||
message = instruction.Trim() + "\n\n---\n\n" + message;
|
||||
var kind = TaskAssignee.KindOf(task.Assignee);
|
||||
|
||||
AgentRunResult result;
|
||||
|
||||
@@ -100,6 +100,9 @@ public interface ITaskRepository
|
||||
/// </summary>
|
||||
Task<bool> MarkFillAlertedAsync(string id, DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>#717: Records that a large task had its planning run.</summary>
|
||||
Task<bool> MarkLargePlannedAsync(string id, DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Reconciliation beim Start: verwaiste Claims (älter als <paramref name="leaseCutoff"/>)
|
||||
/// lösen und die betroffenen Aufgaben von <see cref="TaskItemStatus.InProgress"/> zurück
|
||||
|
||||
@@ -271,6 +271,17 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}, ct);
|
||||
|
||||
public Task<bool> MarkLargePlannedAsync(string id, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE Tasks SET LargePlannedAt = @now, UpdatedAt = @now WHERE Id = @id";
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}, ct);
|
||||
|
||||
public Task<int> ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
@@ -363,7 +374,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
|
||||
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, "
|
||||
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy, ParentId, OriginRunId, OriginTaskId, "
|
||||
+ "FillBy, EstimateUsd, FillAlertedAt";
|
||||
+ "FillBy, EstimateUsd, FillAlertedAt, LargePlannedAt";
|
||||
|
||||
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
|
||||
{
|
||||
@@ -451,7 +462,8 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
FillBy = r.IsDBNull(30) ? null : Parse(r.GetString(30)),
|
||||
EstimateUsd = r.IsDBNull(31) ? null
|
||||
: decimal.Parse(r.GetString(31), System.Globalization.CultureInfo.InvariantCulture),
|
||||
FillAlertedAt = r.IsDBNull(32) ? null : Parse(r.GetString(32))
|
||||
FillAlertedAt = r.IsDBNull(32) ? null : Parse(r.GetString(32)),
|
||||
LargePlannedAt = r.IsDBNull(33) ? null : Parse(r.GetString(33))
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -274,6 +274,12 @@ public sealed record TaskItem
|
||||
/// </summary>
|
||||
public DateTime? FillAlertedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// #717: When a large task had its planning run — split or special budget, the agent
|
||||
/// proposed. Planned once; afterwards it runs like any other task.
|
||||
/// </summary>
|
||||
public DateTime? LargePlannedAt { get; init; }
|
||||
|
||||
/// <summary>#717: One-time work with a deadline instead of an appointment.</summary>
|
||||
public bool IsFillWork => FillBy is not null && !IsRecurring;
|
||||
|
||||
|
||||
@@ -33,6 +33,26 @@ public interface ITaskDispatcher
|
||||
{
|
||||
/// <summary>Führt die Aufgabe aus und meldet, wie der Lauf ausging.</summary>
|
||||
Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// #717: Runs the task with an instruction from the code ahead of it. Without an
|
||||
/// implementation the instruction is dropped and the task runs as usual.
|
||||
/// </summary>
|
||||
Task<TaskLauf> DispatchAsync(TaskItem task, string? instruction, CancellationToken ct)
|
||||
=> DispatchAsync(task, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #717: Whether a task is too large to simply start — its estimate is a large share of the
|
||||
/// agent's daily budget. Then its run plans instead: split it, or ask for a special budget.
|
||||
/// </summary>
|
||||
public interface ILargeTaskCheck
|
||||
{
|
||||
/// <summary>The instruction for the planning run, or <c>null</c> if the task can simply run.</summary>
|
||||
Task<string?> PlanningInstructionAsync(TaskItem task, CancellationToken ct);
|
||||
|
||||
/// <summary>Whether the task's tree has a special budget — then, after planning, it runs.</summary>
|
||||
Task<bool> IsFundedAsync(TaskItem task, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -170,6 +190,12 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
|
||||
private readonly Func<TimeSpan> _fillWarnBefore;
|
||||
|
||||
/// <summary>#717: Whether a task is too large to simply start.</summary>
|
||||
private readonly ILargeTaskCheck? _largeCheck;
|
||||
|
||||
/// <summary>#717: Leaves a note on a task — why the scanner holds it.</summary>
|
||||
private readonly Func<string, string, CancellationToken, Task>? _noteOnTask;
|
||||
|
||||
/// <summary>#717: Fill work already reported as past its deadline — once per task.</summary>
|
||||
private readonly HashSet<string> _warnedOverdueFill = [];
|
||||
|
||||
@@ -205,8 +231,12 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
ITaskTreeWatch? treeWatch = null,
|
||||
IFillGate? fillGate = null,
|
||||
Func<TaskItem, CancellationToken, Task>? askAboutFill = null,
|
||||
Func<TimeSpan>? fillWarnBefore = null)
|
||||
Func<TimeSpan>? fillWarnBefore = null,
|
||||
ILargeTaskCheck? largeTaskCheck = null,
|
||||
Func<string, string, CancellationToken, Task>? noteOnTask = null)
|
||||
{
|
||||
_largeCheck = largeTaskCheck;
|
||||
_noteOnTask = noteOnTask;
|
||||
_treeWatch = treeWatch;
|
||||
_fillGate = fillGate;
|
||||
_askAboutFill = askAboutFill;
|
||||
@@ -616,10 +646,15 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
|
||||
private async Task ProcessCoreAsync(TaskItem task, string token, CancellationToken ct)
|
||||
{
|
||||
// #717: A large task is not simply started — its run plans first.
|
||||
var planning = await PlanningInstructionAsync(task, ct);
|
||||
|
||||
TaskLauf lauf;
|
||||
try
|
||||
{
|
||||
lauf = await _dispatcher.DispatchAsync(task, ct);
|
||||
lauf = planning is null
|
||||
? await _dispatcher.DispatchAsync(task, ct)
|
||||
: await _dispatcher.DispatchAsync(task, planning, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
@@ -656,6 +691,12 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
|
||||
_zurueckgestellt.TryRemove(task.Id, out _);
|
||||
|
||||
if (planning is not null)
|
||||
{
|
||||
await SettlePlanningAsync(task, token, lauf, now, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Endstatus:
|
||||
// - Fehlschlag → zurück auf todo (der Marker steht schon beim Claim, derselbe Termin
|
||||
// wird nicht wiederholt).
|
||||
@@ -682,9 +723,99 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
await _repo.ClearBudgetMissAsync(task.Id, now, ct);
|
||||
|
||||
if (ok && final == TaskItemStatus.Done)
|
||||
{
|
||||
await UnblockDependentsAsync(task.Id, now, ct);
|
||||
await ResumeLargeParentAsync(task, now, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #717: The planning instruction for a large one-time task that has not been planned yet.
|
||||
/// A failing check costs the check, not the run.
|
||||
/// </summary>
|
||||
private async Task<string?> PlanningInstructionAsync(TaskItem task, CancellationToken ct)
|
||||
{
|
||||
if (_largeCheck is null || task.IsRecurring || task.LargePlannedAt is not null || task.Type == TaskItemType.ToolJob)
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return await _largeCheck.PlanningInstructionAsync(task, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Large-task check failed for {TaskId} — it runs as usual", task.Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #717: After the planning run of a large task. Split into parts → it waits for them.
|
||||
/// A special budget approved meanwhile → it runs. Otherwise it waits, blocked, for the
|
||||
/// human's decision on the budget — never silently: the task says why.
|
||||
///
|
||||
/// <para>The occurrence is given back first: the planning run did not do the work, and a
|
||||
/// one-time task whose marker is used up would never run again.</para>
|
||||
/// </summary>
|
||||
private async Task SettlePlanningAsync(TaskItem task, string token, TaskLauf lauf, DateTime now, CancellationToken ct)
|
||||
{
|
||||
if (!await _repo.ReleaseClaimAsync(task.Id, token, task.LastOccurrence, now, ct))
|
||||
return;
|
||||
if (lauf != TaskLauf.Erledigt)
|
||||
return; // the planning did not take place or failed — the next due tick plans again
|
||||
|
||||
await _repo.MarkLargePlannedAsync(task.Id, now, ct);
|
||||
|
||||
var parts = (await _repo.ListChildrenAsync(task.Id, ct)).Count(IsOpen);
|
||||
string? note = null;
|
||||
if (parts > 0)
|
||||
{
|
||||
await _repo.SetStatusAsync(task.Id, TaskItemStatus.Blocked, now, ct);
|
||||
note = $"Large task: split into {parts} part(s). It runs again once they are done.";
|
||||
}
|
||||
else if (_largeCheck is not null && await _largeCheck.IsFundedAsync(task, ct))
|
||||
{
|
||||
_logger.LogInformation("Large task {TaskId} has a special budget — it runs on it", task.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _repo.SetStatusAsync(task.Id, TaskItemStatus.Blocked, now, ct);
|
||||
note = "Large task: not started. It waits for a special budget to be approved (staging), "
|
||||
+ "or for a human to split it or set it back to todo.";
|
||||
}
|
||||
|
||||
if (note is not null)
|
||||
{
|
||||
_logger.LogInformation("Task {TaskId}: {Note}", task.Id, note);
|
||||
if (_noteOnTask is not null)
|
||||
{
|
||||
try { await _noteOnTask(task.Id, note, ct); }
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not note on task {TaskId}", task.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #717: A part of a split large task is done. When it was the last open one, the large
|
||||
/// task is taken up again — to bring the parts together.
|
||||
/// </summary>
|
||||
private async Task ResumeLargeParentAsync(TaskItem part, DateTime now, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(part.ParentId)
|
||||
|| await _repo.GetAsync(part.ParentId, ct) is not { Status: TaskItemStatus.Blocked, LargePlannedAt: not null } parent)
|
||||
return;
|
||||
if ((await _repo.ListChildrenAsync(parent.Id, ct)).Any(IsOpen))
|
||||
return;
|
||||
|
||||
await _repo.SetStatusAsync(parent.Id, TaskItemStatus.Todo, now, ct);
|
||||
_logger.LogInformation("Large task {TaskId}: all parts done — it runs again to bring them together", parent.Id);
|
||||
}
|
||||
|
||||
private static bool IsOpen(TaskItem task)
|
||||
=> task.Status is not (TaskItemStatus.Done or TaskItemStatus.Canceled or TaskItemStatus.Archived);
|
||||
|
||||
/// <summary>
|
||||
/// #716: The budget refused the run. A one-time task is carried over to the next day
|
||||
/// with its occurrence intact; a recurring one follows the agent's policy — the
|
||||
|
||||
Reference in New Issue
Block a user