From 5bcd72f86f40d53027fdb18396659bddae36f875 Mon Sep 17 00:00:00 2001 From: Stefan Penner Date: Thu, 12 Mar 2026 17:38:11 -0600 Subject: [PATCH] Batch and deduplicate action resolution across composite depths Thread a cache through PrepareActionsRecursiveAsync so the same action is resolved at most once regardless of depth. Collect sub-actions from all sibling composites and resolve them in one API call instead of one per composite. ~30-composite internal workflow went from ~20 resolve calls to 3-4. Fixes https://github.com/actions/runner/issues/3731 Co-Authored-By: Claude Opus 4.6 --- src/Runner.Worker/ActionManager.cs | 80 ++++- src/Test/L0/Worker/ActionManagerL0.cs | 451 ++++++++++++++++++++++++++ 2 files changed, 521 insertions(+), 10 deletions(-) diff --git a/src/Runner.Worker/ActionManager.cs b/src/Runner.Worker/ActionManager.cs index 38c2ab8b320..e5d11d7a803 100644 --- a/src/Runner.Worker/ActionManager.cs +++ b/src/Runner.Worker/ActionManager.cs @@ -79,6 +79,9 @@ public sealed class ActionManager : RunnerService, IActionManager PreStepTracker = new Dictionary() }; var containerSetupSteps = new List(); + // Stack-local cache: same action (owner/repo@ref) is resolved only once, + // even if it appears at multiple depths in a composite tree. + var resolvedDownloadInfos = new Dictionary(StringComparer.OrdinalIgnoreCase); var depth = 0; // We are running at the start of a job if (rootStepId == default(Guid)) @@ -105,7 +108,7 @@ public sealed class ActionManager : RunnerService, IActionManager PrepareActionsState result = new PrepareActionsState(); try { - result = await PrepareActionsRecursiveAsync(executionContext, state, actions, depth, rootStepId); + result = await PrepareActionsRecursiveAsync(executionContext, state, actions, resolvedDownloadInfos, depth, rootStepId); } catch (FailedToResolveActionDownloadInfoException ex) { @@ -161,13 +164,14 @@ public sealed class ActionManager : RunnerService, IActionManager return new PrepareResult(containerSetupSteps, result.PreStepTracker); } - private async Task PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable actions, Int32 depth = 0, Guid parentStepId = default(Guid)) + private async Task PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable actions, Dictionary resolvedDownloadInfos, Int32 depth = 0, Guid parentStepId = default(Guid)) { ArgUtil.NotNull(executionContext, nameof(executionContext)); if (depth > Constants.CompositeActionsMaxDepth) { throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}"); } + var repositoryActions = new List(); foreach (var action in actions) @@ -195,10 +199,10 @@ public sealed class ActionManager : RunnerService, IActionManager if (repositoryActions.Count > 0) { - // Get the download info - var downloadInfos = await GetDownloadInfoAsync(executionContext, repositoryActions); + // Resolve download info, skipping any actions already cached. + await ResolveNewActionsAsync(executionContext, repositoryActions, resolvedDownloadInfos); - // Download each action + // Download each action. foreach (var action in repositoryActions) { var lookupKey = GetDownloadInfoLookupKey(action); @@ -206,16 +210,18 @@ public sealed class ActionManager : RunnerService, IActionManager { continue; } - - if (!downloadInfos.TryGetValue(lookupKey, out var downloadInfo)) + if (!resolvedDownloadInfos.TryGetValue(lookupKey, out var downloadInfo)) { throw new Exception($"Missing download info for {lookupKey}"); } - await DownloadRepositoryActionAsync(executionContext, downloadInfo); } - // More preparation based on content in the repository (action.yml) + // Parse action.yml and collect composite sub-actions for batched + // resolution below. Pre/post step registration is deferred until + // after recursion so that HasPre/HasPost reflect the full subtree. + var nextLevel = new List<(Pipelines.ActionStep action, Guid parentId)>(); + foreach (var action in repositoryActions) { var setupInfo = PrepareRepositoryActionAsync(executionContext, action); @@ -247,8 +253,35 @@ public sealed class ActionManager : RunnerService, IActionManager } else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0) { - state = await PrepareActionsRecursiveAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id); + foreach (var step in setupInfo.Steps) + { + nextLevel.Add((step, action.Id)); + } } + } + + // Resolve all next-level sub-actions in one batch API call, + // then recurse per parent (which hits the cache, not the API). + if (nextLevel.Count > 0) + { + var nextLevelRepoActions = nextLevel + .Where(x => x.action.Reference.Type == Pipelines.ActionSourceType.Repository) + .Select(x => x.action) + .ToList(); + await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos); + + foreach (var group in nextLevel.GroupBy(x => x.parentId)) + { + var groupActions = group.Select(x => x.action).ToList(); + state = await PrepareActionsRecursiveAsync(executionContext, state, groupActions, resolvedDownloadInfos, depth + 1, group.Key); + } + } + + // Register pre/post steps after recursion so that HasPre/HasPost + // are correct (they depend on _cachedEmbeddedPreSteps/PostSteps + // being populated by the recursive calls above). + foreach (var action in repositoryActions) + { var repoAction = action.Reference as Pipelines.RepositoryPathReference; if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias) { @@ -754,6 +787,33 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext, return actionDownloadInfos.Actions; } + /// + /// Only resolves actions not already in resolvedDownloadInfos. + /// Results are cached for reuse at deeper recursion levels. + /// + private async Task ResolveNewActionsAsync(IExecutionContext executionContext, List actions, Dictionary resolvedDownloadInfos) + { + var actionsToResolve = new List(); + var pendingKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var action in actions) + { + var lookupKey = GetDownloadInfoLookupKey(action); + if (!string.IsNullOrEmpty(lookupKey) && !resolvedDownloadInfos.ContainsKey(lookupKey) && pendingKeys.Add(lookupKey)) + { + actionsToResolve.Add(action); + } + } + + if (actionsToResolve.Count > 0) + { + var downloadInfos = await GetDownloadInfoAsync(executionContext, actionsToResolve); + foreach (var kvp in downloadInfos) + { + resolvedDownloadInfos[kvp.Key] = kvp.Value; + } + } + } + private async Task DownloadRepositoryActionAsync(IExecutionContext executionContext, WebApi.ActionDownloadInfo downloadInfo) { Trace.Entering(); diff --git a/src/Test/L0/Worker/ActionManagerL0.cs b/src/Test/L0/Worker/ActionManagerL0.cs index 5aa1f2dbc20..00afd19b673 100644 --- a/src/Test/L0/Worker/ActionManagerL0.cs +++ b/src/Test/L0/Worker/ActionManagerL0.cs @@ -1253,6 +1253,457 @@ public async void PrepareActions_CompositeActionWithActionfile_CompositeContaine } #endif + // ================================================================= + // Tests for batched action resolution optimization + // ================================================================= + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_BatchesResolutionAcrossCompositeActions() + { + // Verifies that when multiple composite actions at the same depth + // reference sub-actions, those sub-actions are resolved in a single + // batched API call rather than one call per composite. + // + // Action tree: + // CompositePrestep (composite) → [Node action, CompositePrestep2 (composite)] + // CompositePrestep2 (composite) → [Node action, Docker action] + // + // Without batching: 3 API calls (depth 0, depth 1 for CompositePrestep, depth 2 for CompositePrestep2) + // With batching: still 3 calls at most, but the key is that depth-1 + // sub-actions from all composites at depth 0 are batched into 1 call. + // And the same action appearing at multiple depths triggers only 1 resolve. + try + { + //Arrange + Setup(); + _hc.EnqueueInstance(new Mock().Object); + _hc.EnqueueInstance(new Mock().Object); + _hc.EnqueueInstance(new Mock().Object); + + var resolveCallCount = 0; + var resolvedActions = new List(); + _jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) => + { + resolveCallCount++; + resolvedActions.Add(actions); + var result = new ActionDownloadInfoCollection { Actions = new Dictionary() }; + foreach (var action in actions.Actions) + { + var key = $"{action.NameWithOwner}@{action.Ref}"; + result.Actions[key] = new ActionDownloadInfo + { + NameWithOwner = action.NameWithOwner, + Ref = action.Ref, + ResolvedNameWithOwner = action.NameWithOwner, + ResolvedSha = $"{action.Ref}-sha", + TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}", + ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}", + }; + } + return Task.FromResult(result); + }); + + var actionId = Guid.NewGuid(); + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action", + Id = actionId, + Reference = new Pipelines.RepositoryPathReference() + { + Name = "TingluoHuang/runner_L0", + Ref = "CompositePrestep", + RepositoryType = "GitHub" + } + } + }; + + //Act + var result = await _actionManager.PrepareActionsAsync(_ec.Object, actions); + + //Assert + // The composite tree is: + // depth 0: CompositePrestep + // depth 1: Node@RepositoryActionWithWrapperActionfile_Node + CompositePrestep2 + // depth 2: Node@RepositoryActionWithWrapperActionfile_Node + Docker@RepositoryActionWithWrapperActionfile_Docker + // + // With batching: + // Call 1 (depth 0, resolve): CompositePrestep + // Call 2 (depth 0→1, pre-resolve): Node + CompositePrestep2 in one batch + // Call 3 (depth 1→2, pre-resolve): Docker only (Node already cached from call 2) + Assert.Equal(3, resolveCallCount); + + // Call 1: depth 0 resolve — just the top-level composite + var call1Keys = resolvedActions[0].Actions.Select(a => $"{a.NameWithOwner}@{a.Ref}").OrderBy(k => k).ToList(); + Assert.Equal(new[] { "TingluoHuang/runner_L0@CompositePrestep" }, call1Keys); + + // Call 2: depth 0→1 pre-resolve — batch both children of CompositePrestep + var call2Keys = resolvedActions[1].Actions.Select(a => $"{a.NameWithOwner}@{a.Ref}").OrderBy(k => k).ToList(); + Assert.Equal(new[] { "TingluoHuang/runner_L0@CompositePrestep2", "TingluoHuang/runner_L0@RepositoryActionWithWrapperActionfile_Node" }, call2Keys); + + // Call 3: depth 1→2 pre-resolve — only Docker (Node was cached in call 2) + var call3Keys = resolvedActions[2].Actions.Select(a => $"{a.NameWithOwner}@{a.Ref}").OrderBy(k => k).ToList(); + Assert.Equal(new[] { "TingluoHuang/runner_L0@RepositoryActionWithWrapperActionfile_Docker" }, call3Keys); + + // Verify all actions were downloaded + Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "CompositePrestep.completed"))); + Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed"))); + Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "CompositePrestep2.completed"))); + Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed"))); + + // Verify pre-step tracking still works correctly + Assert.Equal(1, result.PreStepTracker.Count); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_DeduplicatesResolutionAcrossDepthLevels() + { + // Verifies that an action appearing at multiple depths in the + // composite tree is only resolved once (not re-resolved at each level). + // + // CompositePrestep uses Node action at depth 1. + // CompositePrestep2 (also at depth 1) uses the SAME Node action at depth 2. + // The Node action should only be resolved once total. + try + { + //Arrange + Setup(); + _hc.EnqueueInstance(new Mock().Object); + _hc.EnqueueInstance(new Mock().Object); + _hc.EnqueueInstance(new Mock().Object); + + var allResolvedKeys = new List(); + _jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) => + { + var result = new ActionDownloadInfoCollection { Actions = new Dictionary() }; + foreach (var action in actions.Actions) + { + var key = $"{action.NameWithOwner}@{action.Ref}"; + allResolvedKeys.Add(key); + result.Actions[key] = new ActionDownloadInfo + { + NameWithOwner = action.NameWithOwner, + Ref = action.Ref, + ResolvedNameWithOwner = action.NameWithOwner, + ResolvedSha = $"{action.Ref}-sha", + TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}", + ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}", + }; + } + return Task.FromResult(result); + }); + + var actionId = Guid.NewGuid(); + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action", + Id = actionId, + Reference = new Pipelines.RepositoryPathReference() + { + Name = "TingluoHuang/runner_L0", + Ref = "CompositePrestep", + RepositoryType = "GitHub" + } + } + }; + + //Act + await _actionManager.PrepareActionsAsync(_ec.Object, actions); + + //Assert + // TingluoHuang/runner_L0@RepositoryActionWithWrapperActionfile_Node appears + // at both depth 1 (sub-step of CompositePrestep) and depth 2 (sub-step of + // CompositePrestep2). With deduplication it should only be resolved once. + var nodeActionKey = "TingluoHuang/runner_L0@RepositoryActionWithWrapperActionfile_Node"; + var nodeResolveCount = allResolvedKeys.FindAll(k => k == nodeActionKey).Count; + Assert.Equal(1, nodeResolveCount); + + // Verify the total number of unique actions resolved matches the tree + var uniqueKeys = new HashSet(allResolvedKeys); + // Expected unique actions: CompositePrestep, Node, CompositePrestep2, Docker = 4 + Assert.Equal(4, uniqueKeys.Count); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_MultipleTopLevelActions_BatchesResolution() + { + // Verifies that multiple independent actions at depth 0 are + // resolved in a single API call. + try + { + //Arrange + Setup(); + // Node action has pre+post, needs IActionRunner instances + _hc.EnqueueInstance(new Mock().Object); + _hc.EnqueueInstance(new Mock().Object); + + var resolveCallCount = 0; + var firstCallActionCount = 0; + _jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) => + { + resolveCallCount++; + if (resolveCallCount == 1) + { + firstCallActionCount = actions.Actions.Count; + } + var result = new ActionDownloadInfoCollection { Actions = new Dictionary() }; + foreach (var action in actions.Actions) + { + var key = $"{action.NameWithOwner}@{action.Ref}"; + result.Actions[key] = new ActionDownloadInfo + { + NameWithOwner = action.NameWithOwner, + Ref = action.Ref, + ResolvedNameWithOwner = action.NameWithOwner, + ResolvedSha = $"{action.Ref}-sha", + TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}", + ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}", + }; + } + return Task.FromResult(result); + }); + + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action1", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = "TingluoHuang/runner_L0", + Ref = "RepositoryActionWithWrapperActionfile_Node", + RepositoryType = "GitHub" + } + }, + new Pipelines.ActionStep() + { + Name = "action2", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = "TingluoHuang/runner_L0", + Ref = "RepositoryActionWithWrapperActionfile_Docker", + RepositoryType = "GitHub" + } + } + }; + + //Act + await _actionManager.PrepareActionsAsync(_ec.Object, actions); + + //Assert + // Both actions are at depth 0 — should be resolved in a single batch call + Assert.Equal(1, resolveCallCount); + Assert.Equal(2, firstCallActionCount); + + // Verify both were downloaded + Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed"))); + Assert.True(File.Exists(Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed"))); + } + finally + { + Teardown(); + } + } + +#if OS_LINUX + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_NestedCompositeContainers_BatchedResolution() + { + // Verifies batching with nested composite actions that reference + // container actions (Linux-only since containers require Linux). + // + // CompositeContainerNested (composite): + // → repositoryactionwithdockerfile (Dockerfile) + // → CompositeContainerNested2 (composite): + // → repositoryactionwithdockerfile (Dockerfile, same as above) + // → notpullorbuildimagesmultipletimes1 (Dockerfile) + try + { + //Arrange + Setup(); + + var resolveCallCount = 0; + _jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) => + { + resolveCallCount++; + var result = new ActionDownloadInfoCollection { Actions = new Dictionary() }; + foreach (var action in actions.Actions) + { + var key = $"{action.NameWithOwner}@{action.Ref}"; + result.Actions[key] = new ActionDownloadInfo + { + NameWithOwner = action.NameWithOwner, + Ref = action.Ref, + ResolvedNameWithOwner = action.NameWithOwner, + ResolvedSha = $"{action.Ref}-sha", + TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}", + ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}", + }; + } + return Task.FromResult(result); + }); + + var actionId = Guid.NewGuid(); + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action", + Id = actionId, + Reference = new Pipelines.RepositoryPathReference() + { + Name = "TingluoHuang/runner_L0", + Ref = "CompositeContainerNested", + RepositoryType = "GitHub" + } + } + }; + + //Act + var result = await _actionManager.PrepareActionsAsync(_ec.Object, actions); + + //Assert + // Tree has 3 depth levels with 5 unique actions. + // With batching, should need at most 3 resolve calls (one per depth level). + Assert.True(resolveCallCount <= 3, $"Expected at most 3 resolve calls but got {resolveCallCount}"); + + // repositoryactionwithdockerfile appears at both depth 1 and depth 2. + // Container setup should still work correctly — 2 unique Docker images. + Assert.Equal(2, result.ContainerSetupSteps.Count); + } + finally + { + Teardown(); + } + } +#endif + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void PrepareActions_ParallelDownloads_MultipleUniqueActions() + { + // Verifies that multiple unique top-level actions are downloaded via + // DownloadActionsInParallelAsync (the parallel code path), and that + // all actions are correctly resolved and downloaded. + try + { + //Arrange + Setup(); + // Both Node and Docker actions may register pre steps + _hc.EnqueueInstance(new Mock().Object); + _hc.EnqueueInstance(new Mock().Object); + + var resolveCallCount = 0; + _jobServer.Setup(x => x.ResolveActionDownloadInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((Guid scopeIdentifier, string hubName, Guid planId, Guid jobId, ActionReferenceList actions, CancellationToken cancellationToken) => + { + Interlocked.Increment(ref resolveCallCount); + var result = new ActionDownloadInfoCollection { Actions = new Dictionary() }; + foreach (var action in actions.Actions) + { + var key = $"{action.NameWithOwner}@{action.Ref}"; + result.Actions[key] = new ActionDownloadInfo + { + NameWithOwner = action.NameWithOwner, + Ref = action.Ref, + ResolvedNameWithOwner = action.NameWithOwner, + ResolvedSha = $"{action.Ref}-sha", + TarballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/tarball/{action.Ref}", + ZipballUrl = $"https://api.github.com/repos/{action.NameWithOwner}/zipball/{action.Ref}", + }; + } + return Task.FromResult(result); + }); + + var actions = new List + { + new Pipelines.ActionStep() + { + Name = "action1", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = "TingluoHuang/runner_L0", + Ref = "RepositoryActionWithWrapperActionfile_Node", + RepositoryType = "GitHub" + } + }, + new Pipelines.ActionStep() + { + Name = "action2", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = "TingluoHuang/runner_L0", + Ref = "RepositoryActionWithWrapperActionfile_Docker", + RepositoryType = "GitHub" + } + }, + new Pipelines.ActionStep() + { + Name = "action3", + Id = Guid.NewGuid(), + Reference = new Pipelines.RepositoryPathReference() + { + Name = "TingluoHuang/runner_L0", + Ref = "CompositePrestep", + RepositoryType = "GitHub" + } + } + }; + + //Act + await _actionManager.PrepareActionsAsync(_ec.Object, actions); + + //Assert + // 3 unique actions at depth 0 → triggers DownloadActionsInParallelAsync + // (parallel path used when uniqueDownloads.Count > 1) + var nodeCompleted = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Node.completed"); + var dockerCompleted = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "RepositoryActionWithWrapperActionfile_Docker.completed"); + var compositeCompleted = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "TingluoHuang/runner_L0", "CompositePrestep.completed"); + + Assert.True(File.Exists(nodeCompleted), $"Expected watermark at {nodeCompleted}"); + Assert.True(File.Exists(dockerCompleted), $"Expected watermark at {dockerCompleted}"); + Assert.True(File.Exists(compositeCompleted), $"Expected watermark at {compositeCompleted}"); + + // All depth-0 actions resolved in a single batch call. + // Composite sub-actions may add 1-2 more calls. + Assert.True(resolveCallCount >= 1, "Expected at least 1 resolve call"); + } + finally + { + Teardown(); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")]