Skip to content

Cancel sub-workflow child runs that no parent waits for - #2141

Merged
ppXD merged 1 commit into
mainfrom
fix/cancel-unwaited-child-runs
Nov 7, 2026
Merged

ppXD merged 1 commit into
mainfrom
fix/cancel-unwaited-child-runs

Conversation

@ppXD

@ppXD ppXD commented Nov 7, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Cancel sub-workflow child runs that no live parent waits for. StuckRunReconcilerService.CancelUnwaitedChildRunsAsync runs first in ReconcileAsync: non-terminal ChildWorkflow runs older than UnwaitedChildStagingGrace (2 min, committed) with no live parent holding a Pending Subworkflow wait whose Token is the child id are cancelled via IWorkflowService.CancelRunAsync, BatchSize per tick, each in its own try/catch, counted in CancelledUnwaitedChildRun. It runs after the cheap sweeps and in its own catch (a failed find is logged and cannot stop the others). The batch is ordered by LastModifiedDate then CreatedDate; a failed or no-op cancel stamps LastModifiedDate, so stuck children rotate to the back instead of holding every slot.
  • WorkflowRunDispatcher.DispatchAsync refuses a run with a finished ancestor (walks only through sub-workflow children, cycle-safe; rerun/replay lineage never counts), so a still-armed timer cannot re-walk an orphan.
  • One shared RunsWithNoFinishedParent exclusion now backs every reconciler wake query; the "left alone" counting is dropped.
  • ContinueRunAsync refuses (returns false, logs the reason) a run that is a sub-workflow child with a finished ancestor, reusing RunAncestry.
  • CloseEndedAttemptAsync (Continue) now returns the ended attempt's live children, cancelled through the full cancel after commit.

Changed existing methods:

  • StuckRunReconcilerService: constructor (+IWorkflowService), ReconcileAsync, RedispatchStuckPendingAsync, FindStrandedSuspendedIdsAsync, RecoverSupervisorAdvancesAsync, RecoverStrandedTimerWaitsAsync, RecoverStrandedSupervisorInfraParkWaitsAsync, FindRecoverableSupervisorRunsAsync, FindPreClaimSupervisorCrashCandidatesAsync, MarkAbandonedRunningAsync (a Running orphan is cancelled with full teardown by the sweep instead of being marked Failure bare); class is now partial.
  • WorkflowRunDispatcher.DispatchAsync.
  • WorkflowService: ContinueRunAsync, LiveChildRunIdsAsync now uses the shared WorkflowRunState.NonTerminal set; CloseEndedAttemptAsync (return type), ReviveTerminalRunAsync, CancelStagedChildRunsAsync replaced by CancelEndedAttemptChildrenAsync, StagedChildRunIdsAsync replaced by LiveChildRunIdsAsync; doc comments that admitted the gap.
  • ReconcileStuckRunsCommandHandler.Handle, StuckRunReconcileSummary, ReconcileStuckRunsResponse (+counter); IWorkflowRunDispatcher and IWorkflowService docs.

#1990 edits several of the same files; this PR keeps the new logic in new files (StuckRunReconcilerService.UnwaitedChildren.cs, RunAncestry.cs) to ease that rebase.

Test plan

  • Unit (full project): 15169 passed, 1 skipped, 0 failed
  • Integration: StuckRunReconcilerFlowTests 37, UnwaitedChildRunFlowTests 38, DispatcherFinishedAncestorFlowTests 9, SubworkflowCancelFlowTests 8, all Continue integration tests 106 (2 skipped), all green
  • E2E: UnwaitedChildRunE2ETests 3 green
  • Mutations red: drop Token match (30 fail), drop grace period (1), drop dispatcher ancestor check (5), drop CloseEndedAttempt child cancel (2), drop the sweep's catch (1), drop the rotation (1), drop the Continue guard (3)

@ppXD
ppXD force-pushed the fix/cancel-unwaited-child-runs branch 2 times, most recently from e8e8576 to 02a020a Compare November 7, 2026 19:42
A parent that ends while a child is still live (a sibling fails after a
barrier-free wake, a stop's child cancel throws, a Continue stages a fresh
child) left that child running: the engine's failure cleanup cancels only
Pending/Enqueued children, and the child's own timer or self-advance wake
kept re-walking it, spending LLM turns for work nothing reads.

- The stuck-run reconciler now runs CancelUnwaitedChildRunsAsync first:
  non-terminal ChildWorkflow runs past a committed staging grace period
  with no live parent holding a Pending Subworkflow wait naming them are
  cancelled through the full CancelRunAsync, oldest first, in batches,
  each in its own try/catch, with a summary counter. Deeper levels clear
  on later ticks.
- WorkflowRunDispatcher refuses a run with a finished ancestor, walking
  only through sub-workflow children so rerun/replay lineage never counts.
- One shared direct-parent exclusion now guards every reconciler wake
  query, replacing the per-query copies and the "left alone" count.
- Continue's CloseEndedAttemptAsync cancels the ended attempt's live
  children through the full cancel after commit, not only Pending ones.
- The sweep runs after the cheap sweeps and in its own catch, so a failed
  find cannot stop them. A failed or no-op attempt stamps the child's
  LastModifiedDate and the batch is least-recently-attempted first, so
  stuck children cannot hold every slot.
- Continue refuses a sub-workflow child with a finished ancestor, which
  the dispatcher would refuse and the sweep cancel anyway.
- A Running orphan is left to the sweep's full teardown instead of being
  marked abandoned bare.
@ppXD
ppXD merged commit 83a5b79 into main Nov 7, 2026
6 of 7 checks passed
@ppXD
ppXD deleted the fix/cancel-unwaited-child-runs branch November 7, 2026 20:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant