Verweis statt Nacherzaehlung: die Engine stempelt den Ursprung (#697, #677)

- Werkzeugkontext kennt Lauf und Aufgabe des Laufs (RunId, RunTaskId); die
  Delegationskette traegt den Lauf des Aufrufers weiter (RunId, CallerRunId)
- Aufgaben tragen origin_run / origin_task (Datei, DB, Taskboard "Arose from"),
  gestempelt, nie vom Agenten behauptet
- Kommentare nennen den Lauf, in dem sie geschrieben wurden
- Laufbeleg: CallerRunId; Laufansicht fuehrt zu "Delegiert von" und "Delegiert an"
- #677: task_update ersetzt den Rumpf nicht mehr, Abnahmekriterien aendert nur der
  Ersteller; Kommentare der Fremdinhalt-Stufe auf fremden Aufgaben kommen gerahmt an
- WebUI: Herkunft auf der Aufgabenkarte mit Link zum Lauf

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-25 09:27:47 +02:00
co-authored by Claude Opus 5.5
parent 3f90e0d0cd
commit f812dbd856
26 changed files with 493 additions and 47 deletions
@@ -34,11 +34,11 @@ public sealed class SqliteTaskRepository : ITaskRepository
INSERT INTO Tasks
(Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz,
RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName,
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, CreatedBy, ParentId)
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, CreatedBy, ParentId, OriginRunId, OriginTaskId)
VALUES
(@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz,
@requireApproval, @acceptance, @blockedBy, @market, @body, @fileName,
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt, @createdBy, @parentId);
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt, @createdBy, @parentId, @originRun, @originTask);
""";
BindDefinition(insert, task);
insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status));
@@ -62,6 +62,8 @@ public sealed class SqliteTaskRepository : ITaskRepository
FileName = @fileName, ToolName = @toolName, JobTypeId = @jobType,
CreatedBy = CASE WHEN @createdBy = '' THEN CreatedBy ELSE @createdBy END,
ParentId = @parentId,
OriginRunId = CASE WHEN @originRun = '' THEN OriginRunId ELSE @originRun END,
OriginTaskId = CASE WHEN @originTask = '' THEN OriginTaskId ELSE @originTask END,
UpdatedAt = @updatedAt
WHERE Id = @id
""";
@@ -333,7 +335,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
"Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " +
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, "
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy, ParentId";
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy, ParentId, OriginRunId, OriginTaskId";
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
{
@@ -356,6 +358,9 @@ public sealed class SqliteTaskRepository : ITaskRepository
// #684: a file without it (written by hand, or older) keeps what the database knows.
cmd.Parameters.AddWithValue("@createdBy", task.CreatedBy ?? "");
cmd.Parameters.AddWithValue("@parentId", task.ParentId ?? "");
// #697: Stamped once; a file written by hand, or re-imported, keeps what is known.
cmd.Parameters.AddWithValue("@originRun", task.OriginRunId ?? "");
cmd.Parameters.AddWithValue("@originTask", task.OriginTaskId ?? "");
}
private static async Task<TaskItem?> ReadAsync(SqliteConnection conn, string id, CancellationToken ct)
@@ -408,7 +413,9 @@ public sealed class SqliteTaskRepository : ITaskRepository
DeferredUntil = r.IsDBNull(24) ? null : Parse(r.GetString(24)),
CatchUpPending = r.GetInt32(25) != 0,
CreatedBy = r.GetString(26),
ParentId = r.GetString(27)
ParentId = r.GetString(27),
OriginRunId = r.GetString(28),
OriginTaskId = r.GetString(29)
};
}
@@ -154,6 +154,8 @@ public static class TaskFrontmatter
JobTypeId = scalars.GetValueOrDefault("job_type", ""),
CreatedBy = scalars.GetValueOrDefault("created_by", ""),
ParentId = scalars.GetValueOrDefault("parent", ""),
OriginRunId = scalars.GetValueOrDefault("origin_run", ""),
OriginTaskId = scalars.GetValueOrDefault("origin_task", ""),
Body = body
};
return true;
@@ -175,6 +177,10 @@ public static class TaskFrontmatter
sb.Append($"created_by: {Scalar(task.CreatedBy)}\n");
if (!string.IsNullOrWhiteSpace(task.ParentId))
sb.Append($"parent: {Scalar(task.ParentId)}\n");
if (!string.IsNullOrWhiteSpace(task.OriginRunId))
sb.Append($"origin_run: {Scalar(task.OriginRunId)}\n");
if (!string.IsNullOrWhiteSpace(task.OriginTaskId))
sb.Append($"origin_task: {Scalar(task.OriginTaskId)}\n");
if (task.When is { } w)
{
+10
View File
@@ -242,6 +242,16 @@ public sealed record TaskItem
/// </summary>
public string ParentId { get; init; } = "";
/// <summary>
/// #697: The run in which an agent created this task — stamped by the engine, never taken
/// from the agent. With it, whoever decides can go back to the original without asking:
/// what the agent saw and did when it handed this out. Empty for tasks from a human.
/// </summary>
public string OriginRunId { get; init; } = "";
/// <summary>#697: The task that run was working on, if any — the task this one arose from.</summary>
public string OriginTaskId { get; init; } = "";
/// <summary>A one-time task that was refused for lack of budget and carried over.</summary>
public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0;
@@ -99,10 +99,16 @@ public sealed class TaskboardService
}
/// <summary>Hängt einen Kommentar (Ergebnis, Kritik) an den Rumpf an.</summary>
public Task<TaskItem?> AddCommentAsync(string id, string author, string text, CancellationToken ct)
/// <param name="runId">
/// #697: The run the comment was written in — stamped by the engine, so a reader can go
/// back to what the author saw instead of relying on the retelling.
/// </param>
public Task<TaskItem?> AddCommentAsync(string id, string author, string text, CancellationToken ct, string? runId = null)
=> UpdateAsync(id, current =>
{
var stamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm");
if (!string.IsNullOrWhiteSpace(runId))
stamp += $", run {runId}";
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 };