diff --git a/docs/operating/remote-executors.md b/docs/operating/remote-executors.md
index 01578e89..5071f47b 100644
--- a/docs/operating/remote-executors.md
+++ b/docs/operating/remote-executors.md
@@ -7,9 +7,43 @@ phase-execution seam. It connects **outbound** to the orchestrator (plain HTTPS
POSTs) and never opens an inbound listening port, so it can sit behind NAT or
a host firewall.
-This page covers the process, its registration and its liveness. Dispatching
-work to a registered executor is a separate item: an executor that registers
-and heartbeats but is never sent work is the acceptance state.
+This page covers the process, its registration, its liveness, and the
+phase-dispatch proxy that sends work to a registered executor.
+
+## Dispatching phases to an executor
+
+`ExecutorPhaseProxy` (`src/CodeyBox.Orchestrator/ExecutorPhaseProxy.cs`)
+implements `IExecutorPhaseRunner`: it selects a registered executor from the
+worker registry using `ExecutorEligibility` (zero-capacity, cordoned and
+unhealthy hosts register but are never selected), stages the phase's single
+bare repo to the host through `IExecutorPhaseTransport`, runs the phase
+there, and stages the repo back as a tar archive that is validated (archive
+bytes, entry count, expansion ratio, path containment) before anything is
+extracted over the orchestrator's bare repo. The archive-byte cap is enforced
+by the transport while receiving — an unbounded payload is aborted mid-stream
+rather than buffered to disk and rejected afterwards — with the validator
+re-checking the landed size as defense in depth. Only the per-item repo is ever
+transferred — never the whole repos root — so an executor receives only the
+repo for the item it is running.
+
+Delivery is idempotent through `IIdempotencyStore`: the key is work item +
+phase + attempt and the body hash covers the request, so a redelivered
+dispatch replays the original result instead of provisioning a second
+sandbox, while the same key with a different body is refused as a conflict
+and never executes. With no executor registered, dispatch falls back to the
+in-process runner with unchanged behaviour.
+
+An agent failure on the executor is returned as a result (`AgentFailed`); a
+host, connection or transfer problem throws `ExecutorPhaseTransportException`
+and stores nothing, so an unreachable host is retried elsewhere rather than
+charged against the work item as an agent failure. The proxy never touches
+the work item table — the transport carries dispatch only, and re-dispatch
+after failure stays with the pipeline state machine.
+
+Bounds live under `CodeyBox:ExecutorPhaseDispatch` (`StageOutMaxArchiveBytes`,
+`StageOutMaxEntries`, `StageOutMaxExpansionRatio`, `IdempotencyTtl`,
+`MaxRequestPayloadBytes`, `MaxResultFindings`, `MaxFindingLengthChars`,
+`MaxResultErrorLengthChars`), hot-reloadable like the other dispatch knobs.
## Running the executor
diff --git a/src/CodeyBox.Core/ExecutorPhase.cs b/src/CodeyBox.Core/ExecutorPhase.cs
new file mode 100644
index 00000000..5090574e
--- /dev/null
+++ b/src/CodeyBox.Core/ExecutorPhase.cs
@@ -0,0 +1,88 @@
+using System.Text.Json.Serialization;
+
+namespace CodeyBox.Core;
+
+///
+/// Outcome of a phase executed on an executor host (or in process).
+/// A phase that fails because the host was unreachable or the transfer broke
+/// is NOT represented here — that surfaces as
+/// so the caller retries
+/// elsewhere instead of charging the work item with an agent failure.
+///
+public enum ExecutorPhaseOutcome
+{
+ Succeeded = 0,
+ AgentFailed = 1,
+}
+
+///
+/// Resource usage attributed to one phase execution. All values are produced
+/// by the code under test (the executor-side handler or its in-process
+/// twin) and are validated non-negative by the dispatch proxy before they
+/// are cached or returned.
+///
+public sealed record ExecutorPhaseUsage(
+ [property: JsonPropertyName("inputTokens")] long InputTokens,
+ [property: JsonPropertyName("outputTokens")] long OutputTokens,
+ [property: JsonPropertyName("costUsd")] decimal CostUsd);
+
+///
+/// Dispatch envelope for one phase of a work item. The proxy keys idempotent
+/// delivery on work item + phase + attempt: a redelivered dispatch (same key,
+/// same body hash) returns the original result instead of provisioning a
+/// second sandbox, while a new attempt uses a new key and executes fresh.
+///
+public sealed record ExecutorPhaseRequest
+{
+ /// Work item the phase belongs to. Non-empty, at most 128 chars.
+ public required string WorkItemId { get; init; }
+
+ ///
+ /// Phase name (for example "work", "audit", "merge"). Open vocabulary so
+ /// future pipeline phases need no contract change; restricted to
+ /// [A-Za-z0-9_-], at most 64 chars.
+ ///
+ public required string Phase { get; init; }
+
+ ///
+ /// Attempt number within the phase. Must be zero or positive; redelivery
+ /// of the same attempt is idempotent, a new attempt is a new dispatch.
+ ///
+ public required int Attempt { get; init; }
+
+ ///
+ /// Bare-repo id to stage to the executor (normally the work item id).
+ /// Only this repo is transferred — never the whole repos root.
+ ///
+ public required string RepositoryId { get; init; }
+
+ ///
+ /// Serialized phase input (for example the work item snapshot). Bounded
+ /// by dispatch options; covered by the idempotency body hash.
+ ///
+ public required string PayloadJson { get; init; }
+}
+
+///
+/// Result of one phase execution: agent-visible outcome plus the commit the
+/// phase produced, the findings it reported, and the usage it consumed.
+///
+public sealed record ExecutorPhaseResult
+{
+ public required ExecutorPhaseOutcome Outcome { get; init; }
+
+ ///
+ /// Full hex commit sha the phase left on its branch, if it produced one.
+ /// Lowercase hex, 40 (SHA-1) or 64 (SHA-256) chars, or null/empty when
+ /// the phase produced no commit.
+ ///
+ public string? CommitSha { get; init; }
+
+ /// Findings reported by the phase (for example audit findings).
+ public IReadOnlyList Findings { get; init; } = [];
+
+ public required ExecutorPhaseUsage Usage { get; init; }
+
+ /// Agent-facing error detail for .
+ public string? ErrorMessage { get; init; }
+}
diff --git a/src/CodeyBox.Core/ExecutorPhaseTransport.cs b/src/CodeyBox.Core/ExecutorPhaseTransport.cs
new file mode 100644
index 00000000..1303c39f
--- /dev/null
+++ b/src/CodeyBox.Core/ExecutorPhaseTransport.cs
@@ -0,0 +1,131 @@
+namespace CodeyBox.Core;
+
+///
+/// Transport failure moving a phase to or from an executor host: the host was
+/// unreachable, authentication failed, the connection dropped mid-transfer,
+/// or no transport is configured for the selected host. The phase itself did
+/// not fail — the caller must retry elsewhere (another host or the existing
+/// pipeline recovery path) without charging the work item with an agent
+/// failure or consuming a rework iteration. Carries host and operation only.
+///
+public sealed class ExecutorPhaseTransportException : Exception
+{
+ public ExecutorPhaseTransportException(string hostId, string operation, string message)
+ : base($"Executor phase {operation} failed on host '{hostId}': {message}")
+ {
+ HostId = hostId;
+ Operation = operation;
+ }
+
+ public ExecutorPhaseTransportException(string hostId, string operation, string message, Exception inner)
+ : base($"Executor phase {operation} failed on host '{hostId}': {message}", inner)
+ {
+ HostId = hostId;
+ Operation = operation;
+ }
+
+ public string HostId { get; }
+ public string Operation { get; }
+}
+
+///
+/// The phase ran (or was answered) but cannot be accepted: the staged-back
+/// payload exceeded its bounds, failed validation, or the executor's result
+/// was malformed. Distinct from :
+/// the host was reachable, so retrying on another host with the same payload
+/// shape may legitimately fail the same way. The orchestrator's bare repo is
+/// never written when this is thrown, and no idempotency record is stored so
+/// a corrected redelivery can still execute.
+///
+public sealed class ExecutorPhaseException : Exception
+{
+ public ExecutorPhaseException(string message)
+ : base(message)
+ {
+ }
+
+ public ExecutorPhaseException(string message, Exception inner)
+ : base(message, inner)
+ {
+ }
+}
+
+///
+/// Idempotency conflict: the dispatch key (work item + phase + attempt)
+/// already delivered a result for a DIFFERENT request body. The redelivery
+/// must not execute — something is dispatching inconsistent inputs under one
+/// attempt. Carries the key only, never request bodies.
+///
+public sealed class ExecutorPhaseConflictException : Exception
+{
+ public ExecutorPhaseConflictException(string dispatchKey)
+ : base($"Executor phase dispatch key '{dispatchKey}' was already used with a different request body; refusing to execute.")
+ {
+ DispatchKey = dispatchKey;
+ }
+
+ public string DispatchKey { get; }
+}
+
+///
+/// Dispatch-only channel to one executor host. Carries the phase request and
+/// the per-item bare repo to the host and back; it persists no queue state.
+/// The work item table remains the queue of record — re-dispatch after
+/// failure is driven by the existing pipeline state machine and recovery
+/// paths, which call back into the dispatch proxy with a new attempt.
+///
+/// All methods throw on
+/// transport failure. Implementations must stage exactly the repo path they
+/// are given — never the whole repos root — so an executor receives only the
+/// repo for the item it is running. Exception messages must carry host and
+/// operation only: never key material, request bodies, or raw remote output.
+///
+public interface IExecutorPhaseTransport
+{
+ /// Stable executor host id this transport talks to.
+ string HostId { get; }
+
+ ///
+ /// Copies the host-local bare repo at to
+ /// the executor. Called with exactly one per-item repo path per dispatch.
+ ///
+ Task StageInAsync(string hostRepoPath, CancellationToken ct);
+
+ ///
+ /// Runs the phase on the executor against its staged repo copy and
+ /// returns the phase result. An agent failure on the executor is returned
+ /// as a result with , not
+ /// thrown — only transport failures throw.
+ ///
+ Task RunPhaseAsync(ExecutorPhaseRequest request, CancellationToken ct);
+
+ ///
+ /// Writes the executor-side repo back to a host-local tar archive at
+ /// . The caller validates the archive
+ /// (size, entry count, expansion ratio, path containment) before anything
+ /// is extracted over the orchestrator's bare repo.
+ ///
+ /// The transport MUST enforce
+ /// while receiving — aborting the transfer as soon as the cap is
+ /// exceeded — rather than buffering an unbounded payload and reporting
+ /// its size afterwards. The executor is untrusted, so a post-write size
+ /// check alone lets a compromised executor fill the orchestrator disk
+ /// before validation rejects the payload. Exceeding the cap throws
+ /// (a phase failure: the host was
+ /// reachable, the payload was hostile), never a transport exception, and
+ /// must leave no usable archive behind at
+ /// .
+ ///
+ Task StageOutToArchiveAsync(string hostArchivePath, long maxArchiveBytes, CancellationToken ct);
+}
+
+///
+/// Resolves the dispatch transport for a registered executor host. Returns
+/// null when the host has no transport configured (for example no SSH target
+/// is mapped for it); the proxy treats that as a transport failure rather
+/// than silently running the phase elsewhere.
+///
+public interface IExecutorPhaseTransportFactory
+{
+ Task ResolveAsync(string hostId, CancellationToken ct);
+}
diff --git a/src/CodeyBox.Orchestrator/ExecutorPhaseDispatchOptions.cs b/src/CodeyBox.Orchestrator/ExecutorPhaseDispatchOptions.cs
new file mode 100644
index 00000000..8d9051fc
--- /dev/null
+++ b/src/CodeyBox.Orchestrator/ExecutorPhaseDispatchOptions.cs
@@ -0,0 +1,84 @@
+namespace CodeyBox.Orchestrator;
+
+///
+/// Tuning knobs for executor phase dispatch (see ).
+/// Bound under CodeyBox:ExecutorPhaseDispatch. The whole record is
+/// hot-reloadable through a delegate accessor — a config edit lands on the
+/// next dispatch without an orchestrator restart. Operational values live
+/// here, never as literals in the proxy or validator.
+///
+public sealed class ExecutorPhaseDispatchOptions
+{
+ ///
+ /// Maximum tar bytes accepted back from an executor per dispatch. The
+ /// transport enforces this cap while receiving (see
+ /// IExecutorPhaseTransport.StageOutToArchiveAsync) so
+ /// executor-controlled content cannot fill the orchestrator disk before
+ /// validation; the validator re-checks the landed size as defense in
+ /// depth.
+ /// Equivalent to MultipassRemoteSandboxOptions.StageOutMaxArchiveBytes.
+ ///
+ public long StageOutMaxArchiveBytes { get; set; } = 2L * 1024 * 1024 * 1024;
+
+ ///
+ /// Maximum non-metadata tar entries accepted per staged-back archive.
+ /// Equivalent to MultipassRemoteSandboxOptions.StageOutMaxEntries.
+ ///
+ public int StageOutMaxEntries { get; set; } = 200_000;
+
+ ///
+ /// Maximum declared regular-file payload divided by archive bytes.
+ /// Equivalent to MultipassRemoteSandboxOptions.StageOutMaxExpansionRatio.
+ ///
+ public double StageOutMaxExpansionRatio { get; set; } = 1.5d;
+
+ ///
+ /// How long a delivered dispatch result is replayed from the idempotency
+ /// store on redelivery. Mirrors the API idempotency TTL.
+ ///
+ public TimeSpan IdempotencyTtl { get; set; } = TimeSpan.FromHours(24);
+
+ /// Maximum serialized bytes accepted in a dispatch request payload.
+ public int MaxRequestPayloadBytes { get; set; } = 1024 * 1024;
+
+ /// Maximum findings accepted in an executor-returned result.
+ public int MaxResultFindings { get; set; } = 128;
+
+ /// Maximum chars accepted per finding in an executor-returned result.
+ public int MaxFindingLengthChars { get; set; } = 8192;
+
+ /// Maximum chars accepted in an executor-returned error message.
+ public int MaxResultErrorLengthChars { get; set; } = 8192;
+
+ ///
+ /// Fails fast on misconfiguration so a bad bound surfaces at dispatch
+ /// time instead of silently admitting an unbounded payload.
+ ///
+ public void Validate()
+ {
+ if (StageOutMaxArchiveBytes <= 0)
+ throw new InvalidOperationException(
+ "CodeyBox:ExecutorPhaseDispatch:StageOutMaxArchiveBytes must be > 0.");
+ if (StageOutMaxEntries <= 0)
+ throw new InvalidOperationException(
+ "CodeyBox:ExecutorPhaseDispatch:StageOutMaxEntries must be > 0.");
+ if (double.IsNaN(StageOutMaxExpansionRatio) || double.IsInfinity(StageOutMaxExpansionRatio) || StageOutMaxExpansionRatio < 1.0)
+ throw new InvalidOperationException(
+ "CodeyBox:ExecutorPhaseDispatch:StageOutMaxExpansionRatio must be a finite value >= 1.0.");
+ if (IdempotencyTtl <= TimeSpan.Zero)
+ throw new InvalidOperationException(
+ "CodeyBox:ExecutorPhaseDispatch:IdempotencyTtl must be positive.");
+ if (MaxRequestPayloadBytes <= 0)
+ throw new InvalidOperationException(
+ "CodeyBox:ExecutorPhaseDispatch:MaxRequestPayloadBytes must be > 0.");
+ if (MaxResultFindings <= 0)
+ throw new InvalidOperationException(
+ "CodeyBox:ExecutorPhaseDispatch:MaxResultFindings must be > 0.");
+ if (MaxFindingLengthChars <= 0)
+ throw new InvalidOperationException(
+ "CodeyBox:ExecutorPhaseDispatch:MaxFindingLengthChars must be > 0.");
+ if (MaxResultErrorLengthChars <= 0)
+ throw new InvalidOperationException(
+ "CodeyBox:ExecutorPhaseDispatch:MaxResultErrorLengthChars must be > 0.");
+ }
+}
diff --git a/src/CodeyBox.Orchestrator/ExecutorPhaseProxy.cs b/src/CodeyBox.Orchestrator/ExecutorPhaseProxy.cs
new file mode 100644
index 00000000..0cb5d11b
--- /dev/null
+++ b/src/CodeyBox.Orchestrator/ExecutorPhaseProxy.cs
@@ -0,0 +1,381 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using CodeyBox.Core;
+
+namespace CodeyBox.Orchestrator;
+
+///
+/// Proxy that dispatches a phase to a
+/// registered executor host and makes delivery safe to retry.
+///
+/// Per dispatch, in order:
+///
+/// Validate the request bounds and compute the idempotency key (work
+/// item + phase + attempt) plus the SHA-256 body hash.
+/// Look the key up in . A Hit replays the
+/// original result without provisioning a second sandbox; a Conflict (same
+/// key, different body) throws
+/// and never executes.
+/// Select a registered executor from
+/// using (zero-capacity, cordoned and
+/// unhealthy hosts register but are never selected). With no executor
+/// registered, fall back to the in-process runner with unchanged behaviour.
+/// Stage the phase's single bare repo to the executor, run the phase
+/// there, and stage the repo back as a tar archive. Only the per-item repo
+/// path is ever transferred — never the whole repos root.
+/// Validate the staged-back archive (size, entry count, expansion
+/// ratio, path containment) and install it over the bare repo. Violations
+/// fail the phase without writing to the bare repo and without caching.
+/// Cache the result under the dispatch key and return it.
+///
+///
+/// Failure taxonomy: an agent failure on the executor is returned as a
+/// result with ; a host,
+/// connection or transfer problem throws
+/// and stores nothing, so an
+/// unreachable host is retried elsewhere rather than charged against the
+/// work item as an agent failure. The proxy never touches the work item
+/// table — it carries dispatch only; re-dispatch after failure is driven by
+/// the existing pipeline state machine with a new attempt.
+///
+public sealed class ExecutorPhaseProxy : IExecutorPhaseRunner
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+ private static readonly Regex PhaseNamePattern = new("^[A-Za-z0-9_-]{1,64}$", RegexOptions.CultureInvariant | RegexOptions.Compiled);
+
+ private const int MaxWorkItemIdLength = 128;
+ private const int MaxRepositoryIdLength = 256;
+
+ private readonly IWorkerRegistry _registry;
+ private readonly IExecutorPhaseTransportFactory _transports;
+ private readonly IGitHost _gitHost;
+ private readonly IIdempotencyStore _idempotency;
+ private readonly IExecutorPhaseRunner _inner;
+ private readonly Func _optionsAccessor;
+ private readonly TimeProvider _clock;
+ private readonly ILogger _log;
+
+ public ExecutorPhaseProxy(
+ IWorkerRegistry registry,
+ IExecutorPhaseTransportFactory transports,
+ IGitHost gitHost,
+ IIdempotencyStore idempotency,
+ IExecutorPhaseRunner inner,
+ Func optionsAccessor,
+ TimeProvider? clock = null,
+ ILogger? log = null)
+ {
+ _registry = registry ?? throw new ArgumentNullException(nameof(registry));
+ _transports = transports ?? throw new ArgumentNullException(nameof(transports));
+ _gitHost = gitHost ?? throw new ArgumentNullException(nameof(gitHost));
+ _idempotency = idempotency ?? throw new ArgumentNullException(nameof(idempotency));
+ _inner = inner ?? throw new ArgumentNullException(nameof(inner));
+ _optionsAccessor = optionsAccessor ?? throw new ArgumentNullException(nameof(optionsAccessor));
+ _clock = clock ?? TimeProvider.System;
+ _log = log ?? NullLogger.Instance;
+ }
+
+ public async Task ExecutePhaseAsync(ExecutorPhaseRequest request, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var options = _optionsAccessor();
+ options.Validate();
+ ValidateRequest(request, options);
+
+ var dispatchKey = BuildDispatchKey(request);
+ var bodyHash = ComputeBodyHash(request);
+ var now = _clock.GetUtcNow();
+
+ var lookup = await _idempotency.LookupAsync(dispatchKey, bodyHash, now, ct).ConfigureAwait(false);
+ switch (lookup.Outcome)
+ {
+ case IdempotencyLookupOutcome.Hit:
+ _log.LogInformation("Executor phase dispatch {DispatchKey} redelivered; replaying original result", dispatchKey);
+ return ValidateResult(DeserializeCachedResult(dispatchKey, lookup.Entry!), options);
+ case IdempotencyLookupOutcome.Conflict:
+ throw new ExecutorPhaseConflictException(dispatchKey);
+ case IdempotencyLookupOutcome.Miss:
+ break;
+ default:
+ throw new InvalidOperationException($"Unknown idempotency outcome {(int)lookup.Outcome}.");
+ }
+
+ var hostId = await SelectExecutorAsync(ct).ConfigureAwait(false);
+ if (hostId is null)
+ {
+ _log.LogInformation("No executor registered for dispatch {DispatchKey}; falling back to in-process execution", dispatchKey);
+ var fallback = await _inner.ExecutePhaseAsync(request, ct).ConfigureAwait(false);
+ var validatedFallback = ValidateResult(fallback, options);
+ await _idempotency.PutAsync(
+ new IdempotencyEntry(dispatchKey, bodyHash, 200, SerializeResult(validatedFallback), "application/json", now + options.IdempotencyTtl),
+ ct).ConfigureAwait(false);
+ return validatedFallback;
+ }
+
+ var result = await ExecuteRemoteAsync(request, hostId, options, ct).ConfigureAwait(false);
+ await _idempotency.PutAsync(
+ new IdempotencyEntry(dispatchKey, bodyHash, 200, SerializeResult(result), "application/json", now + options.IdempotencyTtl),
+ ct).ConfigureAwait(false);
+ return result;
+ }
+
+ private async Task ExecuteRemoteAsync(
+ ExecutorPhaseRequest request,
+ string hostId,
+ ExecutorPhaseDispatchOptions options,
+ CancellationToken ct)
+ {
+ var transport = await ResolveTransportAsync(hostId, ct).ConfigureAwait(false);
+
+ string repoPath;
+ try
+ {
+ repoPath = _gitHost.GetRepoPath(request.RepositoryId);
+ }
+ catch (NotSupportedException ex)
+ {
+ throw new ExecutorPhaseTransportException(hostId, "resolve-repo", "Git host exposes no local repository path.", ex);
+ }
+ var canonicalRepo = CanonicalizeRepoPath(repoPath, _gitHost.RepositoriesRootDirectory, request.RepositoryId);
+ if (!Directory.Exists(canonicalRepo))
+ throw new ExecutorPhaseException($"Bare repo for '{request.RepositoryId}' does not exist.");
+
+ await CallTransportAsync(hostId, "stage-in", token => transport.StageInAsync(canonicalRepo, token), ct).ConfigureAwait(false);
+
+ var raw = await CallTransportAsync(hostId, "run-phase", token => transport.RunPhaseAsync(request, token), ct).ConfigureAwait(false);
+ var result = ValidateResult(raw, options);
+
+ var scratchRoot = Path.Combine(Path.GetTempPath(), "codeybox-executor-phase-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(scratchRoot);
+ try
+ {
+ var archivePath = Path.Combine(scratchRoot, "stageout.tar");
+ await CallTransportAsync(hostId, "stage-out", token => transport.StageOutToArchiveAsync(archivePath, options.StageOutMaxArchiveBytes, token), ct).ConfigureAwait(false);
+ await ExecutorStageOutValidator.ValidateAndInstallAsync(archivePath, canonicalRepo, scratchRoot, options, ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ try { if (Directory.Exists(scratchRoot)) Directory.Delete(scratchRoot, recursive: true); }
+ catch { }
+ }
+
+ _log.LogInformation("Executor phase dispatch for host {HostId} completed with outcome {Outcome}", hostId, result.Outcome);
+ return result;
+ }
+
+ private async Task ResolveTransportAsync(string hostId, CancellationToken ct)
+ {
+ IExecutorPhaseTransport? transport;
+ try
+ {
+ transport = await _transports.ResolveAsync(hostId, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new ExecutorPhaseTransportException(hostId, "resolve-transport", ex.Message, ex);
+ }
+
+ if (transport is null)
+ throw new ExecutorPhaseTransportException(hostId, "resolve-transport", "No dispatch transport is configured for this host.");
+ return transport;
+ }
+
+ private static async Task CallTransportAsync(
+ string hostId,
+ string operation,
+ Func> call,
+ CancellationToken ct)
+ {
+ try
+ {
+ return await call(ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (ExecutorPhaseTransportException)
+ {
+ throw;
+ }
+ catch (ExecutorPhaseException)
+ {
+ // A stage-out transport that enforces the archive cap (or any
+ // phase-side rejection raised mid-transfer) reports a phase
+ // failure, not a host failure: the host was reachable. Let it
+ // through unwrapped so it is not charged as a transport failure.
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new ExecutorPhaseTransportException(hostId, operation, ex.Message, ex);
+ }
+ }
+
+ private static Task CallTransportAsync(
+ string hostId,
+ string operation,
+ Func call,
+ CancellationToken ct) =>
+ CallTransportAsync