feat(agent): harden loop recovery and permission hook lifecycle
Some checks failed
Release Gate / gate (push) Has been cancelled

This commit is contained in:
2026-04-03 19:24:08 +09:00
parent 0c3921feb5
commit 5de5c74040
8 changed files with 523 additions and 115 deletions

View File

@@ -168,13 +168,11 @@ public sealed class TaskRunService
var restored = new List<TaskRunStore.TaskRun>();
var active = new Dictionary<string, TaskRunStore.TaskRun>(StringComparer.OrdinalIgnoreCase);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var normalizedHistory = NormalizeReplayHistory(history);
if (history != null)
if (normalizedHistory.Count > 0)
{
foreach (var item in history
.OrderBy(x => x.Timestamp)
.ThenBy(x => x.Iteration)
.ThenBy(x => x.ElapsedMs))
foreach (var item in normalizedHistory)
{
var scopedId = TryGetScopedId(item);
if (string.IsNullOrWhiteSpace(scopedId))
@@ -193,7 +191,7 @@ public sealed class TaskRunService
}
}
foreach (var item in history
foreach (var item in normalizedHistory
.OrderByDescending(x => x.Timestamp)
.ThenByDescending(GetReplayPriority)
.ThenByDescending(x => x.Iteration)
@@ -511,6 +509,22 @@ public sealed class TaskRunService
return true;
}
if (string.Equals(item.Type, nameof(Agent.AgentEventType.SkillCall), StringComparison.OrdinalIgnoreCase))
{
task = new TaskRunStore.TaskRun
{
Id = BuildScopedId("tool", item),
Kind = "skill",
Title = string.IsNullOrWhiteSpace(item.ToolName) ? "skill" : item.ToolName,
Summary = item.Summary,
Status = "running",
StartedAt = item.Timestamp,
UpdatedAt = item.Timestamp,
FilePath = item.FilePath,
};
return true;
}
if (string.Equals(item.Type, nameof(Agent.AgentEventType.Thinking), StringComparison.OrdinalIgnoreCase) ||
string.Equals(item.Type, nameof(Agent.AgentEventType.Planning), StringComparison.OrdinalIgnoreCase))
{
@@ -588,6 +602,7 @@ public sealed class TaskRunService
return BuildScopedId("permission", item);
if (string.Equals(item.Type, nameof(Agent.AgentEventType.ToolCall), StringComparison.OrdinalIgnoreCase) ||
string.Equals(item.Type, nameof(Agent.AgentEventType.SkillCall), StringComparison.OrdinalIgnoreCase) ||
string.Equals(item.Type, nameof(Agent.AgentEventType.ToolResult), StringComparison.OrdinalIgnoreCase))
return BuildScopedId("tool", item);
@@ -613,4 +628,73 @@ public sealed class TaskRunService
? $"{prefix}:{suffix}"
: $"{prefix}:{evt.RunId}:{suffix}";
}
private static IReadOnlyList<Models.ChatExecutionEvent> NormalizeReplayHistory(
IEnumerable<Models.ChatExecutionEvent>? history)
{
if (history == null)
return Array.Empty<Models.ChatExecutionEvent>();
var ordered = history
.Where(x => x != null)
.OrderBy(x => x.Timestamp == default ? DateTime.MinValue : x.Timestamp)
.ThenBy(x => x.Iteration)
.ThenBy(x => x.ElapsedMs)
.ToList();
var result = new List<Models.ChatExecutionEvent>(ordered.Count);
foreach (var item in ordered)
{
if (result.Count == 0)
{
result.Add(item);
continue;
}
var last = result[^1];
if (!IsNearDuplicateReplayEvent(last, item))
{
result.Add(item);
continue;
}
last.Timestamp = item.Timestamp;
last.ElapsedMs = Math.Max(last.ElapsedMs, item.ElapsedMs);
last.InputTokens = Math.Max(last.InputTokens, item.InputTokens);
last.OutputTokens = Math.Max(last.OutputTokens, item.OutputTokens);
last.StepCurrent = Math.Max(last.StepCurrent, item.StepCurrent);
last.StepTotal = Math.Max(last.StepTotal, item.StepTotal);
last.Iteration = Math.Max(last.Iteration, item.Iteration);
if (string.IsNullOrWhiteSpace(last.FilePath))
last.FilePath = item.FilePath;
if (!string.IsNullOrWhiteSpace(item.Summary) && item.Summary.Length >= (last.Summary?.Length ?? 0))
last.Summary = item.Summary;
if (item.Steps is { Count: > 0 })
last.Steps = item.Steps.ToList();
if (!string.IsNullOrWhiteSpace(item.ToolInput))
last.ToolInput = item.ToolInput;
last.Success = last.Success && item.Success;
}
return result;
}
private static bool IsNearDuplicateReplayEvent(Models.ChatExecutionEvent left, Models.ChatExecutionEvent right)
{
if (!string.Equals(left.RunId, right.RunId, StringComparison.OrdinalIgnoreCase))
return false;
if (!string.Equals(left.Type, right.Type, StringComparison.OrdinalIgnoreCase))
return false;
if (!string.Equals(left.ToolName, right.ToolName, StringComparison.OrdinalIgnoreCase))
return false;
if (!string.Equals(left.Summary?.Trim(), right.Summary?.Trim(), StringComparison.Ordinal))
return false;
if (!string.Equals(left.FilePath ?? "", right.FilePath ?? "", StringComparison.OrdinalIgnoreCase))
return false;
if (left.Success != right.Success)
return false;
var delta = (right.Timestamp - left.Timestamp).Duration();
return delta <= TimeSpan.FromSeconds(2);
}
}