fix(a2a-client): poll watches crashed on null delays; lost tasks and crashed watches end with a notice
- A2AWatchRegistry.Shared was initialised before DefaultPollDelays (static fields run in source order), so the bot's registry had null poll delays and every poll watch crashed on its first wait, then sat in the registry doing nothing until a restart. The delays now come first; a test reads them through Shared. - TaskNotFound from the remote ends the watch at once with a "(lost)" notice instead of retrying for a day. - A crashed watch loop restarts by polling after a pause; the third crash in a row ends it with a "(stopped)" notice.
This commit is contained in:
@@ -27,6 +27,11 @@ namespace Volcanic.AgenticCore.Extensions.A2A;
|
||||
/// </remarks>
|
||||
public sealed class A2AWatchRegistry : IAsyncDisposable
|
||||
{
|
||||
// Static fields initialise in source order: everything an instance reads (DefaultPollDelays) must come before Shared,
|
||||
// or Shared gets nulls (it did: every poll watch of the bot crashed on its first wait).
|
||||
public static readonly TimeSpan[] DefaultPollDelays =
|
||||
[TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(5)];
|
||||
|
||||
public static A2AWatchRegistry Shared { get; } = new(TimeProvider.System, CreateSharedHandler());
|
||||
|
||||
/// <summary>
|
||||
@@ -61,14 +66,14 @@ public sealed class A2AWatchRegistry : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public static readonly TimeSpan[] DefaultPollDelays =
|
||||
[TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(5)];
|
||||
|
||||
private static readonly TimeSpan DeleteTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>After this many failed checks in a row, a subscribe watch polls instead.</summary>
|
||||
private const int SubscribeFailuresBeforePoll = 3;
|
||||
|
||||
/// <summary>A watch whose loop crashed this many times in a row ends with a <c>(stopped)</c> notice.</summary>
|
||||
private const int CrashesBeforeGivingUp = 3;
|
||||
|
||||
private readonly HttpMessageHandler _handler;
|
||||
|
||||
/// <summary><see cref="_handler"/> behind <see cref="A2AWireCompat"/>, for every A2A call (not for downloads).</summary>
|
||||
@@ -337,7 +342,57 @@ public sealed class A2AWatchRegistry : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a watch until it finishes or is stopped. A crash of its loop (a bug, never a remote failure: those are retried
|
||||
/// inside) restarts the loop after a pause, by polling; after <see cref="CrashesBeforeGivingUp"/> crashes in a row the
|
||||
/// watch ends with a <c>(stopped)</c> notice, so it never sits in the registry doing nothing.
|
||||
/// </summary>
|
||||
private async Task RunAsync(Watch watch, A2AClientSettings settings)
|
||||
{
|
||||
var ct = watch.Stop.Token;
|
||||
try
|
||||
{
|
||||
for (var crashes = 1; ; crashes++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await WatchAsync(watch, settings).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OutOfMemoryException && !ct.IsCancellationRequested)
|
||||
{
|
||||
var record = watch.Record;
|
||||
Logger.LogWarning(ex, "A2A: watch {WatchId} of task {TaskId} of {Agent} crashed ({Count} of {Max})",
|
||||
record.WatchId, record.TaskId, record.Agent, crashes, CrashesBeforeGivingUp);
|
||||
if (crashes >= CrashesBeforeGivingUp)
|
||||
{
|
||||
await FinishAsync(watch, $"[A2A result from \"{record.Agent}\", task {record.TaskId} (stopped)]\n" +
|
||||
$"Watching this task failed ({ex.GetType().Name}), so it is no longer watched. " +
|
||||
$"Check it with action=status, agent={record.Agent}, taskId={record.TaskId}.", null).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
if (record.Source != WatchSources.Poll) await SetSourceAsync(watch, WatchSources.Poll).ConfigureAwait(false);
|
||||
await WaitAsync(watch, DelayAt(ReconnectDelays, crashes - 1), ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception ex) when (ex is not OutOfMemoryException)
|
||||
{
|
||||
// Even the (stopped) notice failed: out of the live watches; its record stays for the next restore.
|
||||
Logger.LogWarning(ex, "A2A: watch {WatchId} crashed", watch.Record.WatchId);
|
||||
_watches.TryRemove(new(watch.Record.WatchId, watch));
|
||||
}
|
||||
finally
|
||||
{
|
||||
watch.DisposeStop();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The watch loop: returns when the watch finished or was stopped, throws only on a bug.</summary>
|
||||
private async Task WatchAsync(Watch watch, A2AClientSettings settings)
|
||||
{
|
||||
var ct = watch.Stop.Token;
|
||||
RemoteAgent? agent = null;
|
||||
@@ -411,6 +466,15 @@ public sealed class A2AWatchRegistry : IAsyncDisposable
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (A2AException ex) when (ex.ErrorCode == A2AErrorCode.TaskNotFound)
|
||||
{
|
||||
// Definitive: the remote forgot the task (typically it restarted), so no check will ever find it.
|
||||
Logger.LogInformation("A2A: task {TaskId} of {Agent} is unknown to the agent; the watch ends", record.TaskId, record.Agent);
|
||||
await FinishAsync(watch, $"[A2A result from \"{record.Agent}\", task {record.TaskId} (lost)]\n" +
|
||||
$"\"{record.Agent}\" no longer knows this task (it was probably restarted), so its result can't be fetched " +
|
||||
"and the task is no longer watched. Send the request again with action=send if it is still needed.", agent).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OutOfMemoryException)
|
||||
{
|
||||
failures++;
|
||||
@@ -431,14 +495,9 @@ public sealed class A2AWatchRegistry : IAsyncDisposable
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception ex) when (ex is not OutOfMemoryException)
|
||||
{
|
||||
Logger.LogWarning(ex, "A2A: watch {WatchId} crashed", watch.Record.WatchId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
agent?.Dispose();
|
||||
watch.DisposeStop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user