diff --git a/backend/src/CodeSpace.Core/Failures/ProviderFailureCapture.cs b/backend/src/CodeSpace.Core/Failures/ProviderFailureCapture.cs
index 4ae4bf710..dbbac81f0 100644
--- a/backend/src/CodeSpace.Core/Failures/ProviderFailureCapture.cs
+++ b/backend/src/CodeSpace.Core/Failures/ProviderFailureCapture.cs
@@ -32,6 +32,13 @@ public static class ProviderFailureCapture
return null;
}
+ ///
+ /// The failure a record already carries, kept only when it is one this capture would have written itself — a provider connection failure
+ /// (). For a node that read a failure back from where it happened (a sub-workflow's child run
+ /// recorded it) instead of catching an exception: the same filter, so what is carried onward is never wider than what is captured.
+ ///
+ public static PersistedFailure? OfRecord(PersistedFailure? recorded) => recorded is not null && FailureCodes.ProviderConnection.Contains(recorded.Code) ? recorded : null;
+
private static PersistedFailure? FromAny(IEnumerable candidates, Guid? repositoryId) =>
candidates.Select(candidate => From(candidate, repositoryId)).FirstOrDefault(found => found is not null);
diff --git a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Assemble.cs b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Assemble.cs
index c8d28364c..df5d489b9 100644
--- a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Assemble.cs
+++ b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Assemble.cs
@@ -55,6 +55,7 @@ private void StateFacts()
RequestedOrigin = _ctx.Failure.Detail("requestedOrigin"),
RedirectFix = RedirectFixFor(),
ConnectedCredentials = _ctx.ConnectedCredentials,
+ Origin = _ctx.Origin,
InstanceName = _ctx.Instance?.DisplayName,
ProviderStatus = _ctx.Failure.Detail("providerStatus"),
OAuthError = _ctx.Failure.Detail("oauthError"),
@@ -96,6 +97,7 @@ private void ChooseActions()
Repositories = _ctx.Repositories.Select(repository => ToRepository(repository)).ToList(),
ProviderInstance = _ctx.Instance is { } instance ? new RemedyProviderInstance { Id = instance.Id, DisplayName = instance.DisplayName, Provider = instance.Provider, BaseUrl = CredentialUrl.Masked(instance.BaseUrl)! } : null,
RedirectsTo = _ctx.Failure.Detail("finalOrigin"),
+ Origin = _ctx.Origin,
Actions = _ctx.Actions,
AskPeople = await WhoToAskAsync(cancellationToken).ConfigureAwait(false)
};
diff --git a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Context.cs b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Context.cs
index 7e6d1a681..0573f7638 100644
--- a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Context.cs
+++ b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Context.cs
@@ -22,6 +22,9 @@ private sealed class RemedyContext
/// False for a refused delivery: there is no run to run again.
public required bool OffersRerun { get; init; }
+ /// The sub-workflow run the failure happened in, when it was carried up to this run from one that is the team's; null otherwise.
+ public RemedyOrigin? Origin { get; init; }
+
public List Repositories { get; set; } = new();
public Dictionary Credentials { get; set; } = new();
@@ -46,6 +49,9 @@ private sealed class RemedyContext
public IReadOnlyList Actions { get; set; } = Array.Empty();
}
+ /// The run a carried failure happened in — as the remedy names it, the repositories it was launched against and, when it was kept, when it recorded the failure.
+ private sealed record OriginRun(RemedyOrigin Origin, IReadOnlyList ScopeRepositoryIds, DateTimeOffset? FailedAt);
+
private sealed record RepositoryRow(Guid Id, string FullPath, RepositoryStatus Status, Guid? CredentialId, Guid ProviderInstanceId);
/// A credential narrowed to what a remedy reads — deliberately NOT the entity, which carries the sealed token.
diff --git a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Load.cs b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Load.cs
index 7ee3d3cb8..6d9ff0b43 100644
--- a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Load.cs
+++ b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Load.cs
@@ -2,6 +2,7 @@
using CodeSpace.Core.Authorization;
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Messages.Authorization;
+using CodeSpace.Messages.Dtos.Credentials;
using CodeSpace.Messages.Enums;
using CodeSpace.Messages.Failures;
using Microsoft.EntityFrameworkCore;
@@ -13,6 +14,23 @@ public sealed partial class ProviderRemedyService
/// The most repositories one remedy names. A run launched against dozens still gets a card a person can read; the count it states is of what it names.
private const int MaxRepositories = 10;
+ ///
+ /// The run a carried failure happened in, read under the asker's team: its workflow's name, the node that ended it and the repositories it was launched
+ /// against. A run that is not the team's, is gone, or is not an authored workflow's names nothing — a pointer in a record is never trusted to be the team's
+ /// own, so it is checked where it is read.
+ ///
+ private async Task ReadOriginAsync(FailureOrigin? origin, Guid teamId, CancellationToken cancellationToken)
+ {
+ if (origin is null) return null;
+
+ var run = await _db.WorkflowRun.AsNoTracking()
+ .Where(r => r.Id == origin.RunId && r.TeamId == teamId && r.WorkflowId != null)
+ .Select(r => new { WorkflowId = r.WorkflowId!.Value, Name = r.Workflow!.Name, r.ScopeRepositoryIds })
+ .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
+
+ return run is null ? null : new OriginRun(new RemedyOrigin { RunId = origin.RunId, WorkflowId = run.WorkflowId, WorkflowName = run.Name, NodeId = origin.NodeId }, run.ScopeRepositoryIds, origin.At);
+ }
+
/// The repositories the failure was about: the one it names, else the ones the run was launched against, else — when it names a credential and nothing else — whatever still leans on that credential.
private async Task LoadRepositoriesAsync(CancellationToken cancellationToken)
{
diff --git a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.cs b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.cs
index 9cdd3787f..e87fd9dde 100644
--- a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.cs
+++ b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.cs
@@ -18,6 +18,11 @@ namespace CodeSpace.Core.Services.Credentials.Remedy;
///
/// Read-only. Nothing here changes a credential, a repository or a run: every action the remedy offers is a request the
/// client makes of the endpoint that already does it, under that endpoint's own authorization.
+///
+/// A failure a flow.subworkflow node carried up from its child run is the child's remedy for the parent's page: the same code, credential,
+/// repositories, resolution (measured from when the child recorded the failure, not from when the parent was resumed) and actions, for the caller's standing in
+/// the PARENT's team (which is the child's, by construction), with the run it happened in named () and Re-run still the run
+/// the page is for. A child that is not this team's run is never named, and changes nothing else about the remedy either.
///
public sealed partial class ProviderRemedyService : IProviderRemedyService, IScopedDependency
{
@@ -42,7 +47,11 @@ public ProviderRemedyService(CodeSpaceDbContext db, ICurrentUser currentUser, Te
if (recorded is null) return null;
- _ctx = new RemedyContext { TeamId = teamId, Code = recorded.Failure.Code, Failure = recorded.Failure, At = recorded.At, ScopeRepositoryIds = recorded.ScopeRepositoryIds, OffersRerun = true };
+ // A failure carried up from a sub-workflow's child run is the child's remedy: traced through the repositories the CHILD was launched against, and judged
+ // "fixed since" from when the CHILD recorded it — as its own page does — because the parent's record is only as old as the last sibling it waited for.
+ var origin = await ReadOriginAsync(recorded.Failure.Origin, teamId, cancellationToken).ConfigureAwait(false);
+
+ _ctx = new RemedyContext { TeamId = teamId, Code = recorded.Failure.Code, Failure = recorded.Failure, At = origin?.FailedAt ?? recorded.At, ScopeRepositoryIds = origin?.ScopeRepositoryIds ?? recorded.ScopeRepositoryIds, Origin = origin?.Origin, OffersRerun = true };
return await BuildAsync(cancellationToken).ConfigureAwait(false);
}
diff --git a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyCopy.cs b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyCopy.cs
index 7f2a430bc..f65191b10 100644
--- a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyCopy.cs
+++ b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyCopy.cs
@@ -1,4 +1,5 @@
using CodeSpace.Messages.Constants;
+using CodeSpace.Messages.Dtos.Credentials;
using CodeSpace.Messages.Failures;
namespace CodeSpace.Core.Services.Credentials.Remedy;
@@ -27,7 +28,14 @@ public static class RemedyCopy
_ => "A provider connection needs attention"
});
- public static string Cause(string code, RemedyFacts facts) => Capitalise(facts.Resolved ? ResolvedCause(code, facts) : code switch
+ /// What happened, in sentences that name the credential and the repository — led, for a failure carried up from a sub-workflow's child run, by the sentence that says which run it happened in.
+ public static string Cause(string code, RemedyFacts facts) => facts.Origin is { } origin ? $"{Where(origin)} {CauseOf(code, facts)}" : CauseOf(code, facts);
+
+ /// Where a failure carried up from a child run happened: the sub-workflow's name, the run (the first eight characters of its id, as the run pages show it) and the node that ended it.
+ private static string Where(RemedyOrigin origin) =>
+ $"This failed in the sub-workflow “{origin.WorkflowName}” (run {origin.RunId.ToString()[..8]}){(origin.NodeId is { Length: > 0 } node ? $", at node '{node}'" : "")}.";
+
+ private static string CauseOf(string code, RemedyFacts facts) => Capitalise(facts.Resolved ? ResolvedCause(code, facts) : code switch
{
FailureCodes.CredentialDisconnected => Disconnected(facts),
FailureCodes.CredentialNeedsReconnect => NeedsReconnect(facts),
diff --git a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyFacts.cs b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyFacts.cs
index b1fdebbe1..1e8c02712 100644
--- a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyFacts.cs
+++ b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyFacts.cs
@@ -1,3 +1,4 @@
+using CodeSpace.Messages.Dtos.Credentials;
using CodeSpace.Messages.Enums;
namespace CodeSpace.Core.Services.Credentials.Remedy;
@@ -64,6 +65,9 @@ public sealed record RemedyFacts
/// For a refused trigger delivery that could not read an author's standing: which of failed. Null for a row written before it was kept, which the sentence then words as either.
public string? Lookup { get; init; }
+ /// The sub-workflow run the failure happened in, when it was carried up to the run the remedy is for; the sentence says where it happened. Null for a failure in the run itself.
+ public RemedyOrigin? Origin { get; init; }
+
/// What the failure names works now.
public bool Resolved { get; init; }
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Engine/SubworkflowService.cs b/backend/src/CodeSpace.Core/Services/Workflows/Engine/SubworkflowService.cs
index d47dcf6e0..2342a2709 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Engine/SubworkflowService.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Engine/SubworkflowService.cs
@@ -3,9 +3,11 @@
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Core.Services.Workflows.Dispatch;
+using CodeSpace.Core.Services.Workflows.Failures;
using CodeSpace.Core.Services.Workflows.RunSources;
using CodeSpace.Messages.Constants;
using CodeSpace.Messages.Enums;
+using CodeSpace.Messages.Failures;
using CodeSpace.Messages.Workflows;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -37,7 +39,8 @@ public interface ISubworkflowService
/// When a child run reaches a terminal state, resume the parent parked on it: find the parent's pending
/// Subworkflow wait whose Token equals this child's id (which also distinguishes a sub-workflow child
/// from a replay child — a replay's "parent" holds no such wait), then resolve it with the child's status + outputs +
- /// error so the node can map them, via the wait-for-all completion path. Returns true iff THIS call resolved the wait
+ /// error — and, for a child that ended on a provider failure a person can repair, that failure and the run it happened in — so the
+ /// node can map them, via the wait-for-all completion path. Returns true iff THIS call resolved the wait
/// (false when there is no such pending wait — not a sub-workflow child, or a concurrent resume / this child's own
/// happy-path resume already won). Idempotent (the wait CAS) and best-effort (a failure is logged, never propagated —
/// the child already completed cleanly). The ONE resume path shared by the engine's on-completion call AND the
@@ -55,14 +58,16 @@ public sealed class SubworkflowService : ISubworkflowService, IScopedDependency
private readonly IRunStarter _runStarter;
private readonly IWorkflowRunDispatcher _dispatcher;
private readonly IWorkflowResumeService _resumeService;
+ private readonly IRunFailureReader _failures;
private readonly ILogger _logger;
- public SubworkflowService(CodeSpaceDbContext db, IRunStarter runStarter, IWorkflowRunDispatcher dispatcher, IWorkflowResumeService resumeService, ILogger logger)
+ public SubworkflowService(CodeSpaceDbContext db, IRunStarter runStarter, IWorkflowRunDispatcher dispatcher, IWorkflowResumeService resumeService, IRunFailureReader failures, ILogger logger)
{
_db = db;
_runStarter = runStarter;
_dispatcher = dispatcher;
_resumeService = resumeService;
+ _failures = failures;
_logger = logger;
}
@@ -123,12 +128,8 @@ public async Task ResumeParentIfChildTerminalAsync(WorkflowRun child, Work
if (waitId is null) return false; // not a sub-workflow child (e.g. a replay), or its wait already resolved
- var payload = JsonSerializer.Serialize(new
- {
- status = status.ToString(),
- outputs = JsonSafeParseObject(child.OutputsJson),
- error,
- });
+ var failure = status == WorkflowRunStatus.Failure ? await ReadChildFailureAsync(child, cancellationToken).ConfigureAwait(false) : null;
+ var payload = BuildResumePayload(child, status, error, failure);
// Resolve ONLY this child's own wait — never the sibling waits a parallel fan-out of children holds — so each
// parent subworkflow node resumes with its own child's outputs. The wait-COMPLETION path's CAS makes a
@@ -144,6 +145,39 @@ public async Task ResumeParentIfChildTerminalAsync(WorkflowRun child, Work
}
}
+ ///
+ /// The provider failure the child's run ended on, read by the reader the child's own run page asks (so the parent can never be told a different
+ /// failure than the child shows), with the run it happened in. A failure the child itself carried up from a child of its own keeps the origin it
+ /// already has — run, node and the time that run recorded it — so one that passed through nested sub-workflows names the deepest run that met it, and
+ /// is judged from when it did. Null when the child ended on anything else, and
+ /// when the record cannot be read: resuming the parent never waits on it, and a parent resumed without it fails with the sentence it always had.
+ ///
+ private async Task ReadChildFailureAsync(WorkflowRun child, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var recorded = await _failures.ReadAsync(child.Id, child.TeamId, cancellationToken).ConfigureAwait(false);
+
+ return recorded is null ? null : recorded.Failure with { Origin = recorded.Failure.Origin ?? new FailureOrigin { RunId = child.Id, NodeId = recorded.NodeId, At = recorded.At } };
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogWarning(ex, "Could not read the failure sub-workflow child {ChildRunId} ended on; its parent resumes with the sentence alone", child.Id);
+
+ return null;
+ }
+ }
+
+ /// The resume payload the sub-workflow node maps: status, outputs and error as it always carried, plus the child's failure under the key a ledger record keeps one under — absent unless there is one, so a payload without a failure is byte-identical to what it was.
+ private static string BuildResumePayload(WorkflowRun child, WorkflowRunStatus status, string? error, PersistedFailure? failure)
+ {
+ var payload = new Dictionary { ["status"] = status.ToString(), ["outputs"] = JsonSafeParseObject(child.OutputsJson), ["error"] = error };
+
+ if (failure is not null) payload[PersistedFailureJson.PayloadKey] = PersistedFailureJson.ToElement(failure);
+
+ return JsonSerializer.Serialize(payload);
+ }
+
/// Parse a run's OutputsJson into a JsonElement object for the resume payload; an empty object on any trouble.
private static JsonElement JsonSafeParseObject(string? json)
{
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Engine/WorkflowEngine.cs b/backend/src/CodeSpace.Core/Services/Workflows/Engine/WorkflowEngine.cs
index 64da768d4..f9815f9a3 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Engine/WorkflowEngine.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Engine/WorkflowEngine.cs
@@ -3423,14 +3423,14 @@ private async Task ExecuteNodeAsync(WorkflowRun run, NodeDefinit
}
catch (SubworkflowStartException ex)
{
- return await FinalizeFailureAsync(exec, ex.Message, thrown: null, cancellationToken).ConfigureAwait(false);
+ return await FinalizeFailureAsync(exec, ex.Message, thrown: null, carried: null, cancellationToken).ConfigureAwait(false);
}
catch (AgentRunAdmissionException ex)
{
// The fail-closed admission cap refused this agent run at staging — a clean node failure (like a
// failed sub-workflow start), so the agent.run node routes to its error edge and, inside a map
// branch, the map's continue-on-error / terminate policy applies. NOT a crash, NOT a leaked wait.
- return await FinalizeFailureAsync(exec, ex.Message, thrown: null, cancellationToken).ConfigureAwait(false);
+ return await FinalizeFailureAsync(exec, ex.Message, thrown: null, carried: null, cancellationToken).ConfigureAwait(false);
}
}
@@ -3450,7 +3450,7 @@ private async Task ExecuteNodeAsync(WorkflowRun run, NodeDefinit
retryable = retryable && (result?.Retryable ?? true);
if (!retryable || attempt >= plan.MaxAttempts)
- return await FinalizeFailureAsync(exec, lastError, thrown ?? result?.Cause, cancellationToken).ConfigureAwait(false);
+ return await FinalizeFailureAsync(exec, lastError, thrown ?? result?.Cause, result?.CarriedFailure, cancellationToken).ConfigureAwait(false);
await LogRetryAndWaitAsync(exec, attempt, plan, lastError, attemptStartedAt, retryAfter, cancellationToken).ConfigureAwait(false);
@@ -3468,7 +3468,7 @@ private async Task ExecuteNodeAsync(WorkflowRun run, NodeDefinit
}
// Defensive: the loop always returns on its final attempt.
- return await FinalizeFailureAsync(exec, lastError, thrown: null, cancellationToken).ConfigureAwait(false);
+ return await FinalizeFailureAsync(exec, lastError, thrown: null, carried: null, cancellationToken).ConfigureAwait(false);
}
///
@@ -3622,9 +3622,10 @@ private async Task FinalizeNonFailureAsync(NodeExecution exec, N
/// once the walker merges it. The scope write happens in ; it's
/// harmless when the node has no error edge — the run fails and scope is dropped.
///
- private async Task FinalizeFailureAsync(NodeExecution exec, string error, Exception? thrown, CancellationToken cancellationToken)
+ private async Task FinalizeFailureAsync(NodeExecution exec, string error, Exception? thrown, PersistedFailure? carried, CancellationToken cancellationToken)
{
- var failure = ProviderFailureCapture.From(thrown, RepositoryActedOn(exec));
+ // What an exception declares wins; failing that, the failure the node itself read back from where it happened (a sub-workflow's child run).
+ var failure = ProviderFailureCapture.From(thrown, RepositoryActedOn(exec)) ?? carried;
await _recordLogger.NodeFailedAsync(exec.Run.Id, exec.Node.Id, exec.IterationKey, RedactForPersistence(exec.Scope, error), DateTimeOffset.UtcNow - exec.StartedAt, failure, cancellationToken).ConfigureAwait(false);
return new NodeRunOutcome(NodeStatus.Failure, BuildErrorOutput(error, exec.Node.Id), null, null);
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Failures/IRunFailureReader.cs b/backend/src/CodeSpace.Core/Services/Workflows/Failures/IRunFailureReader.cs
index e44377cbd..304c2d188 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Failures/IRunFailureReader.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Failures/IRunFailureReader.cs
@@ -11,7 +11,8 @@ public interface IRunFailureReader
///
/// The failure the run ended on, or null: it recorded none, it is not the team's, or it did not fail on a provider connection.
/// For a failed run the node's or run's own record wins and the last agent run's result is the fallback; for a run that did
- /// not fail, only a push the remote refused counts — the run stayed whole, and its branch was not published.
+ /// not fail, only a push the remote refused counts — the run stayed whole, and its branch was not published. The result names the node that ended the
+ /// run (), which is how a parent says where in a child run the failure happened.
///
Task ReadAsync(Guid runId, Guid teamId, CancellationToken cancellationToken);
}
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Failures/RunFailureReader.cs b/backend/src/CodeSpace.Core/Services/Workflows/Failures/RunFailureReader.cs
index e1130ecf4..8625e6d8a 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Failures/RunFailureReader.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Failures/RunFailureReader.cs
@@ -19,7 +19,10 @@ namespace CodeSpace.Core.Services.Workflows.Failures;
/// that failed it (Node 'x' failed.), and that node's record decides — a failure carried there, else the agent run it spawned.
/// A node that failed for another reason, or a run ended by the reconciler, answers null rather than the first failure found.
/// When the node that ended the run is a container (a map, a loop), its own record carries the failure of the cell that ended IT —
-/// the engine writes it there from — so this reads a container exactly as it reads any node.
+/// the engine writes it there from — so this reads a container exactly as it reads any node.
+/// A flow.subworkflow node whose child run ended on a provider failure fails carrying THAT failure — read from the child by this same reader when the
+/// child finished, with the child run it happened in as its — so a parent run reads the same way too, and a chain of
+/// nested sub-workflows reads to the deepest.
///
public sealed class RunFailureReader : IRunFailureReader, IScopedDependency
{
@@ -40,7 +43,7 @@ public sealed class RunFailureReader : IRunFailureReader, IScopedDependency
? await FromWhereTheRunEndedAsync(runId, teamId, run.Error, cancellationToken).ConfigureAwait(false)
: await FromLatestAgentRunAsync(runId, teamId, nodeId: null, publishRefusalOnly: true, cancellationToken).ConfigureAwait(false);
- return found is null ? null : new RecordedFailure { Failure = found.Value.Failure, At = found.Value.At, ScopeRepositoryIds = run.ScopeRepositoryIds };
+ return found is null ? null : new RecordedFailure { Failure = found.Value.Failure, At = found.Value.At, NodeId = found.Value.NodeId, ScopeRepositoryIds = run.ScopeRepositoryIds };
}
private async Task LoadRunAsync(Guid runId, Guid teamId, CancellationToken cancellationToken) =>
@@ -50,11 +53,11 @@ await _db.WorkflowRun.AsNoTracking()
.SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false);
/// The failure the node that ended the run recorded; failing that, its agent run's own account. Where the ledger names no node that ended the run, nothing speaks for it — except a ledger with no node failure at all, which leaves the agent run as the only witness.
- private async Task<(PersistedFailure Failure, DateTimeOffset At)?> FromWhereTheRunEndedAsync(Guid runId, Guid teamId, string? runError, CancellationToken cancellationToken)
+ private async Task<(PersistedFailure Failure, DateTimeOffset At, string? NodeId)?> FromWhereTheRunEndedAsync(Guid runId, Guid teamId, string? runError, CancellationToken cancellationToken)
{
var ended = await ReadLedgerEndAsync(runId, runError, cancellationToken).ConfigureAwait(false);
- if (ended.Failure is { } failure) return (failure, ended.At);
+ if (ended.Failure is { } failure) return (failure, ended.At, ended.NodeId);
return ended.NodeFailuresRecorded && ended.NodeId is null ? null : await FromLatestAgentRunAsync(runId, teamId, ended.NodeId, publishRefusalOnly: false, cancellationToken).ConfigureAwait(false);
}
@@ -101,12 +104,12 @@ private async Task ReadLedgerEndAsync(Guid runId, string? runError, C
/// and was respawned says nothing about why the run ended. narrows it to the one failure a
/// run that finished can carry — its branch was refused by the remote.
///
- private async Task<(PersistedFailure Failure, DateTimeOffset At)?> FromLatestAgentRunAsync(Guid runId, Guid teamId, string? nodeId, bool publishRefusalOnly, CancellationToken cancellationToken)
+ private async Task<(PersistedFailure Failure, DateTimeOffset At, string? NodeId)?> FromLatestAgentRunAsync(Guid runId, Guid teamId, string? nodeId, bool publishRefusalOnly, CancellationToken cancellationToken)
{
var latest = await _db.AgentRun.AsNoTracking()
.Where(a => a.WorkflowRunId == runId && a.TeamId == teamId && a.ResultJson != null && (nodeId == null || a.NodeId == nodeId))
.OrderByDescending(a => a.CreatedDate)
- .Select(a => new { a.ResultJson, At = a.CompletedAt ?? a.LastModifiedDate })
+ .Select(a => new { a.ResultJson, a.NodeId, At = a.CompletedAt ?? a.LastModifiedDate })
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (latest is null) return null;
@@ -115,7 +118,7 @@ private async Task ReadLedgerEndAsync(Guid runId, string? runError, C
if (failure is null || (publishRefusalOnly && failure.Code != FailureCodes.GitCredentialRejected)) return null;
- return (failure, latest.At);
+ return (failure, latest.At, latest.NodeId);
}
/// The failure an agent run's stored result names: its own failure, else a push refused for the credential — the single-repo result's, or a multi-repo result's first refused repository.
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/FlowSubworkflowNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/FlowSubworkflowNode.cs
index d8d5567f0..7ccbc21d3 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/FlowSubworkflowNode.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/FlowSubworkflowNode.cs
@@ -1,6 +1,9 @@
using System.Text.Json;
+using CodeSpace.Core.Failures;
+using CodeSpace.Core.Services.Workflows.Failures;
using CodeSpace.Messages.Constants;
using CodeSpace.Messages.Enums;
+using CodeSpace.Messages.Failures;
namespace CodeSpace.Core.Services.Workflows.Nodes.Builtin;
@@ -13,7 +16,10 @@ namespace CodeSpace.Core.Services.Workflows.Nodes.Builtin;
/// node's inputs as the child's payload) and parks the parent. When the child reaches a
/// terminal state the engine resumes the parent with the child's outcome, which the node maps:
/// child Success → this node's outputs ARE the child's outputs; child Failure → this node fails,
-/// composing with the node's retry policy + error branch like any other failure.
+/// composing with the node's retry policy + error branch like any other failure. A child that
+/// ended on a provider failure a person can repair (a disconnected credential, a revoked token) reaches
+/// the node on the same payload, and the node fails carrying it, so the parent's own record — and the
+/// remedy built from it — says what is wrong and which child run it happened in.
///
/// The node itself is pure — it never touches the DB. The engine owns the cross-run
/// orchestration (staging, the suspend wait, the deferred dispatch, the completion → resume hook),
@@ -69,7 +75,7 @@ public sealed class FlowSubworkflowNode : INodeRuntime
public Task RunAsync(NodeRunContext context, CancellationToken cancellationToken)
{
- // Resumed: the child finished. ResumePayload = { status, outputs, error } (stamped by the
+ // Resumed: the child finished. ResumePayload = { status, outputs, error, failure? } (stamped by the
// engine's completion hook). Map it onto this node's result.
if (context.ResumePayload.HasValue)
{
@@ -78,7 +84,10 @@ public Task RunAsync(NodeRunContext context, CancellationToken cance
return Task.FromResult(NodeResult.Ok(ReadObjectBag(payload, "outputs")));
var error = ReadString(payload, "error");
- return Task.FromResult(NodeResult.Fail($"Sub-workflow run failed: {(string.IsNullOrEmpty(error) ? "see the child run" : error)}"));
+ var sentence = $"Sub-workflow run failed: {(string.IsNullOrEmpty(error) ? "see the child run" : error)}";
+
+ // The sentence is the node's own and stays as it was; the provider failure the child ended on, when it recorded one, rides beside it.
+ return Task.FromResult(ReadChildFailure(payload) is { } failure ? NodeResult.Fail(sentence, failure) : NodeResult.Fail(sentence));
}
// First pass: validate config + suspend with the spec. The engine stages + dispatches the
@@ -103,6 +112,10 @@ public Task RunAsync(NodeRunContext context, CancellationToken cance
}));
}
+ /// The provider failure the child's run ended on, as the engine's completion hook read it off the child's own record — absent for a child that failed for any other reason, and for a payload stamped before children carried theirs.
+ private static PersistedFailure? ReadChildFailure(JsonElement payload) =>
+ payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty(PersistedFailureJson.PayloadKey, out var failure) ? ProviderFailureCapture.OfRecord(PersistedFailureJson.ReadElement(failure)) : null;
+
private static string ReadString(JsonElement bag, string key) =>
bag.ValueKind == JsonValueKind.Object && bag.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() ?? ""
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/NodeResult.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/NodeResult.cs
index f39ba7645..4e29b77e6 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/NodeResult.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/NodeResult.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using CodeSpace.Messages.Enums;
+using CodeSpace.Messages.Failures;
namespace CodeSpace.Core.Services.Workflows.Nodes;
@@ -31,6 +32,14 @@ public sealed record NodeResult
///
public Exception? Cause { get; init; }
+ ///
+ /// A provider failure the node already holds in its recorded form, when what it failed on happened somewhere else and was read back — a
+ /// sub-workflow's child run ended on it — so there is no exception to hand over as . The engine writes it beside the
+ /// sentence exactly as it writes the failure it reads off an exception, and an exception's own declared failure wins over it. Like
+ /// it never decides a retry.
+ ///
+ public PersistedFailure? CarriedFailure { get; init; }
+
/// Set when Status == Suspended. Opaque token the resume mechanism feeds back.
public SuspensionToken? SuspendUntil { get; init; }
@@ -55,6 +64,9 @@ public static NodeResult Route(IReadOnlyList handles, IReadOnlyDictionar
/// A failure worded by the node, keeping the exception it was worded from () so what it declared can be recorded.
public static NodeResult Fail(string error, Exception cause) => new() { Status = NodeStatus.Failure, Error = error, Cause = cause };
+ /// A failure worded by the node, keeping the failure it was read back as () so what was recorded where it happened is recorded here too.
+ public static NodeResult Fail(string error, PersistedFailure carried) => new() { Status = NodeStatus.Failure, Error = error, CarriedFailure = carried };
+
/// A failure with an explicit retry verdict — false marks a deterministic outcome the retry policy must not re-run.
public static NodeResult Fail(string error, bool retryable) => new() { Status = NodeStatus.Failure, Error = error, Retryable = retryable };
diff --git a/backend/src/CodeSpace.Messages/Dtos/Credentials/ProviderRemedy.cs b/backend/src/CodeSpace.Messages/Dtos/Credentials/ProviderRemedy.cs
index e4c7a194d..609296cc2 100644
--- a/backend/src/CodeSpace.Messages/Dtos/Credentials/ProviderRemedy.cs
+++ b/backend/src/CodeSpace.Messages/Dtos/Credentials/ProviderRemedy.cs
@@ -40,6 +40,9 @@ public sealed record ProviderRemedy
/// For provider_base_url_redirects: the origin the provider answered with, what its base URL would be edited to. Null for every other failure.
public string? RedirectsTo { get; init; }
+ /// Where the failure happened when it was not in the run this remedy is for: the sub-workflow run that met it, which this run's flow.subworkflow node ran. Null for a failure that happened in the run itself — and for one whose sub-workflow run is not the team's or is gone, which is never named.
+ public RemedyOrigin? Origin { get; init; }
+
/// What can be done, best first, each saying whether the caller may do it and, when not, why. Never empty.
public required IReadOnlyList Actions { get; init; }
@@ -47,6 +50,21 @@ public sealed record ProviderRemedy
public IReadOnlyList AskPeople { get; init; } = Array.Empty();
}
+/// The sub-workflow run a failure happened in, as the run page names it: which run (to link to), which workflow it ran and the node that ended it. Re-running is still the PARENT's — this only says where to look.
+public sealed record RemedyOrigin
+{
+ /// The sub-workflow run — the deepest one, when sub-workflows are nested.
+ public required Guid RunId { get; init; }
+
+ public required Guid WorkflowId { get; init; }
+
+ /// The name of the workflow that run executed, read under the asker's team.
+ public required string WorkflowName { get; init; }
+
+ /// The node that ended that run; null when the run's own record carried the failure.
+ public string? NodeId { get; init; }
+}
+
/// The kinds of fix the remedy offers. Serialized as the lower-case names a client branches on, so a rename is a breaking change.
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum RemedyActionKind
diff --git a/backend/src/CodeSpace.Messages/Failures/FailureOrigin.cs b/backend/src/CodeSpace.Messages/Failures/FailureOrigin.cs
new file mode 100644
index 000000000..212dff862
--- /dev/null
+++ b/backend/src/CodeSpace.Messages/Failures/FailureOrigin.cs
@@ -0,0 +1,25 @@
+namespace CodeSpace.Messages.Failures;
+
+///
+/// Where a failure happened when it did not happen in the run whose record carries it: the sub-workflow run that met the provider
+/// failure, and the node that ended it. A flow.subworkflow node fails with the failure its child run ended on, so the parent's
+/// own record can say what is wrong; this is the pointer that says which child run that was, so the parent's page can name it and link to it.
+///
+/// The DEEPEST run that met the failure, never an intermediate one: a failure that travelled through nested sub-workflows is
+/// carried by every level with the origin it was first stamped with. Only identifiers and a time are kept — the run, the node and when
+/// that run recorded the failure — because what the run is called and what it was launched against are read live, under the asker's team, when the remedy is built.
+///
+/// The time matters because the parent that carries the failure is resumed only once every sub-workflow it is waiting on has finished, so its own
+/// record can be much later than the failure; whether the credential has been fixed since is measured from the failure, not from the resume.
+///
+public sealed record FailureOrigin
+{
+ /// The run whose own record names the failure — the child, or the deepest child of a chain of them.
+ public required Guid RunId { get; init; }
+
+ /// The node that ended that run. Null where the run's own record carried the failure (a run that could not start) and no node did.
+ public string? NodeId { get; init; }
+
+ /// When that run recorded the failure. Null in a record written before it was kept, which is then judged from the time of the record that carries it, as it always was.
+ public DateTimeOffset? At { get; init; }
+}
diff --git a/backend/src/CodeSpace.Messages/Failures/PersistedFailure.cs b/backend/src/CodeSpace.Messages/Failures/PersistedFailure.cs
index 722b7c34a..ac6c251be 100644
--- a/backend/src/CodeSpace.Messages/Failures/PersistedFailure.cs
+++ b/backend/src/CodeSpace.Messages/Failures/PersistedFailure.cs
@@ -25,6 +25,9 @@ public sealed record PersistedFailure
/// The repository the failing step was acting on, when the step names one — the subject a remedy re-links. A failure that carries a credential of its own does not need it, and one that carries none (a provider's 401) cannot be traced to a credential without it.
public Guid? RepositoryId { get; init; }
+ /// Where the failure happened when it was not in the run that records it — a sub-workflow's run, carried up to the parent node that ran it (). Absent for a failure that happened in the run itself, and for every record written before sub-workflows carried their failures, which then reads exactly as it did.
+ public FailureOrigin? Origin { get; init; }
+
public string? Detail(string key) => Details is not null && Details.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : null;
public Guid? DetailGuid(string key) => Guid.TryParse(Detail(key), out var id) && id != Guid.Empty ? id : null;
diff --git a/backend/src/CodeSpace.Messages/Failures/RecordedFailure.cs b/backend/src/CodeSpace.Messages/Failures/RecordedFailure.cs
index 95548bbbe..9b845f141 100644
--- a/backend/src/CodeSpace.Messages/Failures/RecordedFailure.cs
+++ b/backend/src/CodeSpace.Messages/Failures/RecordedFailure.cs
@@ -13,6 +13,9 @@ public sealed record RecordedFailure
/// When the record naming the failure was written — the node's failing, or the agent run's ending.
public required DateTimeOffset At { get; init; }
+ /// The node that ended the run and recorded the failure — the one the run's own error names. Null where the run's record itself carried the failure (a run that could not start) or an agent run was the only witness and named no node.
+ public string? NodeId { get; init; }
+
/// The repositories the run was launched against; empty for a run launched with none (an authored workflow).
public IReadOnlyList ScopeRepositoryIds { get; init; } = Array.Empty();
}
diff --git a/backend/tests/CodeSpace.E2ETests/Credentials/ProviderFailureInASubWorkflowHttpE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Credentials/ProviderFailureInASubWorkflowHttpE2ETests.cs
new file mode 100644
index 000000000..825ec4caf
--- /dev/null
+++ b/backend/tests/CodeSpace.E2ETests/Credentials/ProviderFailureInASubWorkflowHttpE2ETests.cs
@@ -0,0 +1,318 @@
+using System.Net;
+using System.Text.Json;
+using CodeSpace.Core.Failures;
+using CodeSpace.Core.Persistence.Db;
+using CodeSpace.Core.Persistence.Entities;
+using CodeSpace.Core.Services.Credentials;
+using CodeSpace.Core.Services.Workflows.Lifecycle;
+using CodeSpace.E2ETests.Infrastructure;
+using CodeSpace.IntegrationTests.Infrastructure;
+using CodeSpace.Messages.Constants;
+using CodeSpace.Messages.Credentials;
+using CodeSpace.Messages.Enums;
+using CodeSpace.Messages.Exceptions;
+using CodeSpace.Messages.Failures;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+
+namespace CodeSpace.E2ETests.Credentials;
+
+///
+/// 🟢 High fidelity (HTTP surface) — a team authors a workflow that runs ANOTHER workflow as a sub-workflow, the child lists pull requests through a
+/// GitLab connection whose OAuth grant is dead, the parent is run, and the PARENT's page is read the way the SPA reads it. Everything runs through the real
+/// ASP.NET pipeline (JWT, the X-Team-Id scope, the controllers and their JSON options) over real Postgres: both workflows are saved with
+/// POST /api/workflows, the parent is started with POST /api/workflows/{id}/run, and the real engine (the in-test deferred queue is the Hangfire
+/// transport) parks the parent, runs the child against a loopback , resumes the parent with the child's outcome and fails
+/// it — then GET /api/workflows/runs/{id} answers as an Owner, a Member and a Viewer.
+///
+/// The parent's page says what the child's does — the credential, the repository, each role's buttons — and names the child run it happened in, which the
+/// page links to. Re-run on the parent is the parent's: it replays the PARENT, which starts a fresh child. A parent recorded before children carried their
+/// failures answers exactly as it did, and a pointer to a run that is not the team's is never named.
+///
+[Trait("Category", "E2E")]
+[Trait("Surface", "Http")]
+public sealed class ProviderFailureInASubWorkflowHttpE2ETests : IClassFixture
+{
+ private const string ChildName = "Review the pull requests";
+
+ private readonly TaskLaunchApiFactory _factory;
+ private readonly TenantHarness _tenants;
+
+ public ProviderFailureInASubWorkflowHttpE2ETests(TaskLaunchApiFactory factory)
+ {
+ _factory = factory;
+ _tenants = new TenantHarness(factory);
+ }
+
+ [Fact]
+ public async Task A_parent_run_whose_child_ended_on_a_dead_connection_carries_the_childs_remedy_naming_the_child_run_for_an_owner_a_member_and_a_viewer()
+ {
+ using var gitlab = new GitLabDoorkeeperStub(); // never heard of the pair the row holds: the refresh is answered invalid_grant
+ var world = await SeedWorldAsync(gitlab);
+ var parentRunId = await RunParentAsync(world);
+ var childRunId = await ChildRunOfAsync(world, parentRunId);
+
+ foreach (var (asker, label, reconnect, relink) in new[] { (world.Owner, "owner", true, true), (world.Member, "member", false, false), (world.Viewer, "viewer", false, false) })
+ {
+ var response = await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{parentRunId}", asker, world.Team);
+ var body = await response.Content.ReadAsStringAsync();
+
+ response.StatusCode.ShouldBe(HttpStatusCode.OK, $"{label}: {body}");
+
+ var run = JsonDocument.Parse(body).RootElement;
+ run.GetProperty("status").GetString().ShouldBe("Failure", $"{label}: fixture check: the child's failure ended the parent");
+ run.GetProperty("error").GetString().ShouldBe("Node 'sub' failed.", $"{label}: the sentence is untouched");
+
+ var remedy = run.GetProperty("remedy");
+ remedy.GetProperty("code").GetString().ShouldBe("credential_needs_reconnect", label);
+ remedy.GetProperty("resolved").GetBoolean().ShouldBeFalse(label);
+ remedy.GetProperty("title").GetString().ShouldBe("GitLab no longer accepts the credential this run used", label);
+ remedy.GetProperty("cause").GetString().ShouldStartWith($"This failed in the sub-workflow “{ChildName}” (run {childRunId.ToString()[..8]}), at node 'list'. ", Case.Sensitive, label);
+ remedy.GetProperty("credential").GetProperty("id").GetGuid().ShouldBe(world.CredentialId, label);
+ remedy.GetProperty("repositories")[0].GetProperty("fullPath").GetString().ShouldBe("acme/api", label);
+
+ var origin = remedy.GetProperty("origin");
+ origin.GetProperty("runId").GetGuid().ShouldBe(childRunId, $"{label}: the child run the page links to");
+ origin.GetProperty("workflowId").GetGuid().ShouldBe(world.ChildWorkflowId, label);
+ origin.GetProperty("workflowName").GetString().ShouldBe(ChildName, label);
+ origin.GetProperty("nodeId").GetString().ShouldBe("list", label);
+
+ var actions = remedy.GetProperty("actions").EnumerateArray().ToDictionary(a => a.GetProperty("kind").GetString()!, a => a);
+ actions["reconnect"].GetProperty("enabled").GetBoolean().ShouldBe(reconnect, $"{label} reconnect");
+ actions["relink"].GetProperty("enabled").GetBoolean().ShouldBe(relink, $"{label} relink");
+ actions["open_providers"].GetProperty("enabled").GetBoolean().ShouldBeTrue(label);
+ actions["rerun"].GetProperty("enabled").GetBoolean().ShouldBeFalse($"{label}: nothing is fixed yet");
+ }
+
+ var lean = await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{parentRunId}/remedy", world.Viewer, world.Team);
+
+ lean.StatusCode.ShouldBe(HttpStatusCode.OK, "the room reads the same remedy without the run detail around it");
+ JsonDocument.Parse(await lean.Content.ReadAsStringAsync()).RootElement.GetProperty("origin").GetProperty("runId").GetGuid().ShouldBe(childRunId);
+
+ // The child's own page says what happened there, and has nowhere else to point.
+ var child = JsonDocument.Parse(await (await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{childRunId}", world.Owner, world.Team)).Content.ReadAsStringAsync()).RootElement;
+ var childRemedy = child.GetProperty("remedy");
+
+ childRemedy.GetProperty("code").GetString().ShouldBe("credential_needs_reconnect");
+ (!childRemedy.TryGetProperty("origin", out var childOrigin) || childOrigin.ValueKind == JsonValueKind.Null).ShouldBeTrue("the failure happened in the child run itself");
+ childRemedy.GetProperty("cause").GetString().ShouldNotContain("sub-workflow", Case.Insensitive);
+
+ // The wire contract: `origin` is the only member this adds. Every member a client that predates it reads is still there, so an older SPA (and a mixed fleet's older API) loses nothing.
+ var members = childRemedy.EnumerateObject().Select(p => p.Name).ToList();
+
+ members.ShouldBeSubsetOf(new[] { "code", "title", "cause", "resolved", "credential", "repositories", "providerInstance", "redirectsTo", "origin", "actions", "askPeople" }, "no member beyond the one this adds");
+ members.ShouldContain("code");
+ members.ShouldContain("title");
+ members.ShouldContain("cause");
+ members.ShouldContain("resolved");
+ members.ShouldContain("credential");
+ members.ShouldContain("repositories");
+ members.ShouldContain("actions");
+ members.ShouldContain("askPeople");
+ }
+
+ [Fact]
+ public async Task Re_running_the_parent_replays_the_parent_and_starts_a_fresh_child_while_the_original_child_is_left_as_it_was()
+ {
+ using var gitlab = new GitLabDoorkeeperStub();
+ var world = await SeedWorldAsync(gitlab);
+ var parentRunId = await RunParentAsync(world);
+ var firstChildRunId = await ChildRunOfAsync(world, parentRunId);
+
+ var replay = await _tenants.SendAsync(HttpMethod.Post, $"/api/workflows/runs/{parentRunId}/replay", world.Owner, world.Team);
+ var replayRunId = Guid.Parse(await ReadStringAsync(replay, "runId"));
+
+ await _factory.JobClient.DrainAsync();
+
+ replayRunId.ShouldNotBe(parentRunId, "the Re-run is a fresh run of the parent");
+
+ var replayed = JsonDocument.Parse(await (await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{replayRunId}", world.Owner, world.Team)).Content.ReadAsStringAsync()).RootElement;
+ var freshChildRunId = replayed.GetProperty("nodes").EnumerateArray().Single(n => n.GetProperty("nodeId").GetString() == "sub").GetProperty("childRunId").GetGuid();
+
+ freshChildRunId.ShouldNotBe(firstChildRunId, "the parent started a FRESH child, never reusing the failed one");
+ replayed.GetProperty("status").GetString().ShouldBe("Failure", "the connection is still dead, so the fresh child fails on it again");
+ replayed.GetProperty("remedy").GetProperty("origin").GetProperty("runId").GetGuid().ShouldBe(freshChildRunId, "and the replayed parent's page names the run it happened in this time");
+
+ var original = JsonDocument.Parse(await (await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{parentRunId}", world.Owner, world.Team)).Content.ReadAsStringAsync()).RootElement;
+
+ original.GetProperty("remedy").GetProperty("origin").GetProperty("runId").GetGuid().ShouldBe(firstChildRunId, "the original run still points at the child it had");
+ }
+
+ [Fact]
+ public async Task A_parent_recorded_before_children_carried_their_failures_answers_exactly_as_it_did()
+ {
+ var world = await SeedWorldAsync(gitlab: null);
+ var parentRunId = await SeedFailedParentAsync(world);
+
+ using (var scope = _factory.Services.CreateScope())
+ {
+ var logger = scope.ServiceProvider.GetRequiredService();
+
+ await logger.NodeStartedAsync(parentRunId, "sub", string.Empty, new Dictionary(), new Dictionary(), CancellationToken.None);
+ await logger.NodeFailedAsync(parentRunId, "sub", string.Empty, "Sub-workflow run failed: Node 'list' failed.", TimeSpan.FromMilliseconds(9), CancellationToken.None);
+ }
+
+ var detail = await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{parentRunId}", world.Owner, world.Team);
+ var run = JsonDocument.Parse(await detail.Content.ReadAsStringAsync()).RootElement;
+
+ detail.StatusCode.ShouldBe(HttpStatusCode.OK);
+ run.GetProperty("error").GetString().ShouldBe("Node 'sub' failed.");
+ (!run.TryGetProperty("remedy", out var remedy) || remedy.ValueKind == JsonValueKind.Null).ShouldBeTrue("the node recorded no failure, so nothing claims a remedy");
+ (await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{parentRunId}/remedy", world.Owner, world.Team)).StatusCode.ShouldBe(HttpStatusCode.NoContent);
+ }
+
+ [Fact]
+ public async Task A_pointer_to_a_run_that_is_not_the_teams_is_never_named_on_the_wire()
+ {
+ var world = await SeedWorldAsync(gitlab: null);
+ var elsewhere = await SeedWorldAsync(gitlab: null, childName: "THE-OTHER-TEAMS-SECRET-WORKFLOW");
+ var foreignRunId = Guid.Parse(await ReadStringAsync(await _tenants.SendAsync(HttpMethod.Post, $"/api/workflows/{elsewhere.ChildWorkflowId}/run", elsewhere.Owner, elsewhere.Team, new { }), "runId"));
+ var parentRunId = await SeedFailedParentAsync(world);
+
+ await _factory.JobClient.DrainAsync();
+
+ using (var scope = _factory.Services.CreateScope())
+ {
+ var logger = scope.ServiceProvider.GetRequiredService();
+ var failure = ProviderFailureCapture.From(new CredentialNeedsReconnectException(world.CredentialId, "GitLab · NACHO.L", "invalid_grant"), world.RepositoryId)! with { Origin = new FailureOrigin { RunId = foreignRunId, NodeId = "list" } };
+
+ await logger.NodeStartedAsync(parentRunId, "sub", string.Empty, new Dictionary(), new Dictionary(), CancellationToken.None);
+ await logger.NodeFailedAsync(parentRunId, "sub", string.Empty, "Sub-workflow run failed: Node 'list' failed.", TimeSpan.FromMilliseconds(9), failure, CancellationToken.None);
+ }
+
+ var detail = await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{parentRunId}", world.Owner, world.Team);
+ var body = await detail.Content.ReadAsStringAsync();
+
+ detail.StatusCode.ShouldBe(HttpStatusCode.OK, body);
+ body.ShouldNotContain("THE-OTHER-TEAMS-SECRET-WORKFLOW");
+ body.ShouldNotContain(foreignRunId.ToString());
+
+ var remedy = JsonDocument.Parse(body).RootElement.GetProperty("remedy");
+
+ remedy.GetProperty("credential").GetProperty("id").GetGuid().ShouldBe(world.CredentialId, "the remedy is still the parent's own");
+ (!remedy.TryGetProperty("origin", out var origin) || origin.ValueKind == JsonValueKind.Null).ShouldBeTrue("an origin that is not this team's run names nothing");
+
+ // And the other team cannot read this team's parent at all.
+ (await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{parentRunId}/remedy", elsewhere.Owner, elsewhere.Team)).StatusCode.ShouldBeOneOf(HttpStatusCode.NoContent, HttpStatusCode.NotFound);
+ }
+
+ // ─── Seeds ─────────────────────────────────────────────────────────────────
+
+ private sealed record World(Guid Team, Guid Owner, Guid Member, Guid Viewer, Guid CredentialId, Guid RepositoryId, Guid ChildWorkflowId, Guid ParentWorkflowId);
+
+ /// A team of an Owner, a Member and a Viewer; one GitLab connection — an OAuth grant due for renewal that the stub never issued, belonging to the Owner as a connect leaves it — and one repository running through it; a child workflow that lists its pull requests and a parent that runs it.
+ private async Task SeedWorldAsync(GitLabDoorkeeperStub? gitlab, string childName = ChildName)
+ {
+ var owner = await _tenants.AccountAsync();
+ var member = await _tenants.AccountAsync();
+ var viewer = await _tenants.AccountAsync();
+ var team = await _tenants.TeamAsync((owner, TeamRole.Owner), (member, TeamRole.Member), (viewer, TeamRole.Viewer));
+ var baseUrl = gitlab?.BaseUrl ?? $"http://127.0.0.1:1/{Guid.NewGuid():N}";
+ var instanceId = Guid.NewGuid();
+ var credentialId = Guid.NewGuid();
+ var repositoryId = Guid.NewGuid();
+
+ using (var scope = _factory.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var encryptor = scope.ServiceProvider.GetRequiredService();
+ var serializer = scope.ServiceProvider.GetRequiredService();
+ var payload = new OAuthPayload { AccessToken = "at-0", RefreshToken = "rt-0", ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1) };
+
+ db.ProviderInstance.Add(new ProviderInstance { Id = instanceId, TeamId = team, Provider = ProviderKind.GitLab, DisplayName = "gitlab", BaseUrl = baseUrl, ApiUrl = baseUrl, OauthClientId = GitLabDoorkeeperStub.DefaultClientId, OauthClientSecretEnc = encryptor.Encrypt(GitLabDoorkeeperStub.DefaultClientSecret) });
+ db.Credential.Add(new Credential { Id = credentialId, TeamId = team, ProviderInstanceId = instanceId, AuthType = AuthType.OAuth, Ownership = CredentialOwnership.Personal, OwnerUserId = owner, DisplayName = "GitLab · NACHO.L", EncryptedPayload = encryptor.Encrypt(serializer.Serialize(payload)), ExpiresDate = payload.ExpiresAt, Status = CredentialStatus.Active });
+ db.Repository.Add(new Repository { Id = repositoryId, TeamId = team, ProviderInstanceId = instanceId, CredentialId = credentialId, ExternalId = Random.Shared.Next(100_000, 999_999).ToString(), NamespacePath = "acme", Name = "api", FullPath = "acme/api", DefaultBranch = "main", Visibility = RepositoryVisibility.Private, WebUrl = $"{baseUrl}/acme/api", CloneUrlHttps = $"{baseUrl}/acme/api.git", Status = RepositoryStatus.Active });
+ await db.SaveChangesAsync();
+ }
+
+ var childWorkflowId = Guid.Parse(await ReadStringAsync(await _tenants.SendAsync(HttpMethod.Post, "/api/workflows", owner, team, ChildBody(repositoryId, childName)), "id"));
+ var parentWorkflowId = Guid.Parse(await ReadStringAsync(await _tenants.SendAsync(HttpMethod.Post, "/api/workflows", owner, team, ParentBody(childWorkflowId)), "id"));
+
+ return new World(team, owner, member, viewer, credentialId, repositoryId, childWorkflowId, parentWorkflowId);
+ }
+
+ /// Starts the parent with POST /api/workflows/{id}/run and lets the engine walk it: parent parks, child fails on the dead connection, the child's completion resumes the parent, which fails.
+ private async Task RunParentAsync(World world)
+ {
+ var runId = Guid.Parse(await ReadStringAsync(await _tenants.SendAsync(HttpMethod.Post, $"/api/workflows/{world.ParentWorkflowId}/run", world.Owner, world.Team, new { }), "runId"));
+
+ await _factory.JobClient.DrainAsync();
+
+ return runId;
+ }
+
+ private async Task ChildRunOfAsync(World world, Guid parentRunId)
+ {
+ var detail = JsonDocument.Parse(await (await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{parentRunId}", world.Owner, world.Team)).Content.ReadAsStringAsync()).RootElement;
+
+ return detail.GetProperty("nodes").EnumerateArray().Single(n => n.GetProperty("nodeId").GetString() == "sub").GetProperty("childRunId").GetGuid();
+ }
+
+ /// A run of the team's that ended on the sub node, written the way the engine ends one — for a run whose ledger a test writes by hand.
+ private async Task SeedFailedParentAsync(World world)
+ {
+ var runId = Guid.NewGuid();
+ var requestId = Guid.NewGuid();
+ var now = DateTimeOffset.UtcNow;
+
+ await _tenants.DbAsync(async db =>
+ {
+ db.WorkflowRunRequest.Add(new WorkflowRunRequest { Id = requestId, TeamId = world.Team, WorkflowId = null, SourceType = WorkflowRunSourceTypes.Snapshot, ActorType = "user", ActorId = SystemUsers.SeederId, NormalizedPayloadJson = "{}", Status = WorkflowRunRequestStatus.Consumed, ReceivedAt = now, VerifiedAt = now, NormalizedAt = now });
+ db.WorkflowRun.Add(new WorkflowRun { Id = runId, WorkflowId = null, WorkflowVersion = null, TeamId = world.Team, RunRequestId = requestId, SourceType = WorkflowRunSourceTypes.Snapshot, Status = WorkflowRunStatus.Failure, Error = "Node 'sub' failed.", ScopeRepositoryIds = [], ScopeProjectIds = [], CreatedDate = now, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId });
+ await db.SaveChangesAsync();
+ return 0;
+ });
+
+ return runId;
+ }
+
+ // start → list (git.list_prs) → end. No error edge: the dead connection ends the run.
+ private static object ChildBody(Guid repositoryId, string name) => new
+ {
+ name,
+ definition = new
+ {
+ schemaVersion = 1,
+ nodes = new object[]
+ {
+ new { id = "start", typeKey = "trigger.manual", config = new { }, inputs = new { } },
+ new { id = "list", typeKey = "git.list_prs", config = new { }, inputs = new { repositoryId } },
+ new { id = "end", typeKey = "builtin.terminal", config = new { }, inputs = new { } },
+ },
+ edges = new object[] { new { from = "start", to = "list" }, new { from = "list", to = "end" } },
+ },
+ activations = Array.Empty