* feat(workflows-runtime): add quiescence machinery foundation for graceful shutdown Introduces the container-scoped quiescence signal, ingress-source contract, burst registry, and the Interrupted workflow sub-status — the foundational primitives the drain orchestrator and admin endpoints will build on. No behaviour change yet: workflows continue to run and shut down exactly as before. The new types are registered but no host-stop or pause path drives them. Highlights: * IQuiescenceSignal — composable Drain + AdministrativePause flags; forward-only drain, reversible pause, idempotent transitions, optional persistence via IKeyValueStore. * IIngressSource + IForceStoppable — uniform contract for components that inject external events (HTTP, schedulers, message consumers, internal workers, third-party modules); IIngressSourceRegistry collects and surfaces their states. * IBurstRegistry — atomic counter for in-flight workflow execution bursts, with per-burst ingress attribution and FR-018 inconsistency detection (a source claiming Paused but starting bursts is flipped to PauseFailed). * WorkflowSubStatus.Interrupted — new value distinct from Suspended, Cancelled, Faulted; semantics: "last burst force-cancelled by graceful drain; resumable on next runtime generation". Mirrored on the API client enum. * GracefulShutdownOptions — drain deadline, per-source pause timeout, stimulus-queue back-pressure policy, pause-persistence policy. Configurable via UseWorkflowRuntime(...).ConfigureGracefulShutdown(...). * PermissionNames.ManageWorkflowRuntime — single permission for the forthcoming admin pause/resume/status/force endpoints. Implements 31 of 77 tasks for the graceful-shutdown feature (specs/002-graceful-shutdown). Subsequent commits add the drain orchestrator (US1 / MVP), Interrupted recovery scan (US3), admin endpoints (US2), and first-party ingress adapters. Tests: 25 new xUnit unit tests; 100/100 runtime unit tests pass; all existing tests continue to pass on net8.0/net9.0/net10.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(workflows-runtime): add drain orchestrator + host-stop integration (US1, MVP) When the host receives a stop signal (SIGTERM, Ctrl+C, orchestrator rollout), the runtime now drains gracefully: ingress sources are paused in parallel, in-flight workflow bursts run to their next natural persistence boundary within a configurable deadline, and any burst that breaches the deadline is force-cancelled and persisted with the Interrupted sub-status plus a forensic WorkflowInterrupted log entry. This is the MVP — without the activation-time recovery scan (PR 3) the existing timeout-based RestartInterruptedWorkflowsTask still picks up Interrupted instances, just on its periodic cadence. No regression in that recovery path (SC-008). Highlights: * IDrainOrchestrator + DrainOrchestrator — protocol per the contract: BeginDrainAsync → parallel ingress pause with per-source timeouts + IForceStoppable escalation → poll BurstRegistry.ActiveCount until zero or deadline → on breach iterate live handles, cancel, persist Interrupted, write log entry. All exceptions are captured into the returned DrainOutcome; only second-invocation throws. * Deadline clamping: effective deadline is min(GracefulShutdownOptions. DrainDeadline, HostOptions.ShutdownTimeout - 500ms safety epsilon), so the runtime never outlives its host process. * DrainOrchestratorHostedService — IHostedService.StopAsync wakes the orchestrator on host stop. Registered AFTER the heartbeat (Elsa.Hosting.Management) so reverse-order shutdown keeps the heartbeat alive throughout drain. Prevents sibling-node crash recovery from false-positive-recovering instances we are gracefully handling here (FR-029). * BurstTrackingMiddleware — workflow-execution-pipeline middleware that registers a BurstHandle for the lifetime of every burst. All nine IWorkflowRunner.RunAsync overloads ultimately funnel into pipeline.ExecuteAsync(context), so this single middleware covers the three "burst choke points" the spec references without nine separate decorators. Added to UseDefaultPipeline(). * Ingress attribution: optional IngressSourceName property on DispatchStimulusRequest, DispatchWorkflowDefinitionRequest, and DispatchWorkflowInstanceRequest. Adapters set it; the middleware reads it via WorkflowExecutionContext.TransientProperties (helpers in IngressAttributionExtensions). The BurstRegistry uses the name to detect the FR-018 invariant violation — a source that reports Paused but starts a burst is flipped to PauseFailed. * InterruptedLogExtensions — the LogWorkflowInterruptedAsync helper that the orchestrator calls when persisting the forensic record. Tests: 11 new unit tests (DrainOrchestrator parallel-pause + wait-for-bursts + idempotency + persistence-failure path); 5 new integration tests (full DI graph resolves, burst-tracking middleware registers handles end-to-end, no-op drain returns CompletedWithinDeadline). 100/100 runtime unit tests pass; all existing tests continue to pass on net8.0/net9.0/net10.0. Implements 14 of 77 tasks (T032–T045). Subsequent commits add Interrupted recovery scan (US3), admin endpoints (US2), and first-party ingress adapters. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin-endpoints): add admin endpoints for workflow runtime control Introduced admin endpoints to manage workflow runtime: `/pause`, `/resume`, `/status`, and `/force` with full authentication and audit logging. Integrated idempotency checks and error handling to ensure reliable runtime control. Added corresponding integration tests for verification. * fix(workflows-runtime): propagate drain cancellation into running workflows + initialize persisted pause + fix log-comment Addresses three findings from the PR #7424 code review: 1. **HIGH — Cancellation now propagates into the running workflow.** `BurstHandle.Cancel()` previously cancelled only its own linked CTS, which the workflow runner never observes (the runner reads from `WorkflowExecutionContext.CancellationToken`, captured at context construction and not part of the linked chain). On deadline breach the orchestrator would persist `Interrupted`, but the workflow continued executing and could overwrite the sub-status with whatever terminal state it eventually reached. Fix: `BurstHandle` accepts an optional cancel callback at construction. `BurstTrackingMiddleware` wires it to `context.Cancel()` so the burst's cancellation triggers the workflow's own cancellation chain — the workflow transitions to `Cancelled` and stops scheduling new activities. The orchestrator then awaits `BurstHandle.Disposed` (with a 2 s settle timeout) before persisting `Interrupted`, ensuring the runner's terminal commit completes BEFORE the orchestrator overwrites the sub-status. Race resolved. The settle timeout is bounded so a non-cancellable activity (genuinely pathological case) does not block drain — on timeout the orchestrator logs and proceeds, accepting the runner-clobber for that one instance, which the existing timeout-based RestartInterruptedWorkflows recovery picks up afterwards. 2. **MEDIUM — Pause persistence is now actually wired.** `QuiescenceSignal.InitializePersistedStateAsync` was implemented but nothing called it on host startup. A host configured with `PausePersistence = AcrossReactivations` would write the persisted key on pause, but on subsequent activation the new `QuiescenceSignal` instance would never read it, so the runtime would resume dispatching despite the operator having paused. Fix: `InitializePauseStateStartupTask : IStartupTask` reads the policy and calls `InitializePersistedStateAsync` once per activation when the policy demands it. Registered in both `WorkflowRuntimeFeature` flavours alongside the other graceful-shutdown services. 3. **MEDIUM — Comment in `DrainOrchestrator.PersistInterruptedAsync` no longer lies.** The previous comment promised a "synthetic log entry" that the next statement (`return`) prevented from being written. The comment is now honest about what actually happens: when no instance row exists, no log entry is emitted, but the burst metadata is still captured in the drain outcome's logged warning so operators have a forensic trail. Tests: * New unit tests on `BurstRegistry` (now 9, was 6): cancel-callback is invoked, callback exceptions are swallowed (drain remains best-effort), `BurstHandle.Disposed` completes on dispose. * Full suites continue to pass: 103/103 runtime unit tests; 247/247 workflow integration tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(workflows-runtime): address PR review feedback + close runner-clobber race + add e2e drain test Six issues raised in PR #7424 review (one e2e gap, five comments inline): 1. **Runner-clobber race closed via ICommitStateHandler decorator.** The previous fix wired `BurstHandle.Cancel()` to `WorkflowExecutionContext.Cancel()` so the workflow's cancellation chain fires on deadline breach, but the e2e test exposed that `BurstHandle` disposed at the END of the pipeline middleware (i.e., BEFORE `WorkflowRunner` calls `commitStateHandler.CommitAsync`). The orchestrator's `await handle.Disposed` therefore returned too early, the instance row didn't yet exist, and the orchestrator's Interrupted write was either a no-op (no row) or got clobbered by the runner's subsequent Cancelled commit. Fix: `BurstAwareCommitStateHandler` decorates `ICommitStateHandler`. The middleware no longer disposes the handle in the success path — it stores the handle in `WorkflowExecutionContext.TransientProperties`, and the decorator disposes it AFTER `inner.CommitAsync` completes. The exception path in the middleware still disposes for safety. Result: the orchestrator's await-disposed sequencing now correctly lands the Interrupted write last. 2. **C1: Null-instance log entry.** `DrainOrchestrator.PersistInterruptedAsync` now writes a synthetic `WorkflowInterrupted` log entry directly when no instance row exists, populating only the fields it knows. Previously the forensic trail was lost. 3. **C2: Force endpoint cached-outcome audit.** Added `WasCached` flag to `DrainOutcome` (default false). The orchestrator sets it on the cached return path (`_previousOutcome with { WasCached = true }`). The force endpoint now skips the audit notification when the flag is true, so repeated force calls no longer emit spurious `RuntimeForceRequested` events (SC-007 idempotency restored). 4. **C3: `StateChanged` raised under lock — deadlock risk closed.** `QuiescenceSignal.BeginDrainAsync`/`PauseAsync`/`ResumeAsync` now do their transitions under the lock, capture whether a transition occurred, release the lock, and only then invoke `RaiseStateChanged`. Subscribers that synchronously call back into the signal can no longer deadlock. 5. **C4: Scheduling source name.** Renamed `scheduling.cron` → `scheduling.triggers` to honestly reflect the four trigger types the adapter covers (Cron, Timer, StartAt, Delay). The name is surfaced verbatim in admin status responses. 6. **C5: Hardcoded Retry-After.** `HttpWorkflowsMiddleware`'s 503 response now sets a reason-aware `Retry-After`: 5 s during drain (host is exiting and will be replaced shortly), 60 s during administrative pause (indefinite, so a longer back-off avoids tight retry loops). Tests: * New e2e `DeadlineBreachEndToEndTests` (2 tests): verifies that drain against a real running workflow detects the in-flight burst, force-cancels it, persists the instance as `Interrupted`, and writes a `WorkflowInterrupted` log entry — closing the test gap that hid the cancellation-propagation issue identified in the previous review pass. * Updated `OperatorForceAfterPreviousReturnsCachedOutcome` to assert value-equality + the `WasCached` flag instead of reference-equality (records use `with` for the cached return path). Full suites pass: 103/103 runtime unit, 249/249 workflow integration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(workflows-api): move runtime admin endpoints into Elsa.Workflows.Api Per PR review feedback: rather than introducing a new sub-module (Elsa.Workflows.Runtime.Admin) for the four pause/resume/status/force endpoints, fold them into the existing Elsa.Workflows.Api project. That project already references both Elsa.Workflows.Runtime and Elsa.Api.Common (FastEndpoints) and is the established home for client-facing workflow APIs — so the admin endpoints belong there. Changes: * New folder src/modules/Elsa.Workflows.Api/Endpoints/RuntimeAdmin/ with Models.cs and Pause/Resume/Status/Force/Endpoint.cs. Namespaces moved from `Elsa.Workflows.Runtime.Admin` → `Elsa.Workflows.Api.Endpoints.RuntimeAdmin`. * Deleted src/modules/Elsa.Workflows.Runtime.Admin/ entirely and removed it from Elsa.sln. The ShellFeature marker class (WorkflowRuntimeAdminFeature) is no longer needed — the existing WorkflowsApiFeature already discovers FastEndpoints in the Workflows.Api assembly. * No consumer changes: the endpoints sit in the same routes (/admin/workflow-runtime/*) and behave identically. Note on the second architectural point ("update IShellFeature if cleaner"): the IShellFeature contract is defined in the external CShells NuGet package, not in this repo, so we cannot add a DeactivateAsync hook without an upstream CShells change. The current IHostedService.StopAsync hook continues to work correctly for the host-stop path; per-shell deactivation would require either a CShells upstream addition or a separate Elsa-owned shell-feature variant — neither lighter than what we have today. Tests: 103/103 runtime unit + 249/249 workflow integration pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(common): introduce IsFatal exception extension + apply to drain best-effort catches Per PR review feedback on the static-analyzer "Generic catch clause" comments: rather than catching ALL exceptions in best-effort drain code paths, narrow the swallow to non-fatal exceptions. Process-fatal conditions (StackOverflowException, AccessViolationException, SEHException, ThreadAbortException, OutOfMemoryException) propagate so the host's failure-fast policy can act on them, while normal failures (InvalidOperationException, IOException, etc.) continue to be logged and allowed through so a single misbehaving ingress source / activity cannot abort the overall drain. Highlights: * New `Elsa.Common.Extensions.ExceptionExtensions.IsFatal` utility: classifies fatal conditions, unwraps reflection-style wrappers (TypeInitializationException, TargetInvocationException) before classification, and treats InsufficientMemoryException (the recoverable OOM subclass) as non-fatal. * Applied as a `when (!ex.IsFatal())` filter to: - BurstHandle.Cancel (cancel callback try/catch) - DrainOrchestrator.PauseOneSourceAsync (per-source exception path) - DrainOrchestrator.TryForceStopAsync - DrainOrchestrator.ForceCancelActiveBurstsAsync (per-burst loop) - DrainOrchestrator.PersistInterruptedAsync (orphan log write, instance save, log write) - DrainOrchestrator.DrainAsync outer catch (existing `not InvalidOperationException` filter extended) - InterruptedRecoveryScan (per-instance restart loop) Tests: 7 new unit tests for IsFatal classification (fatal types, recoverable types, wrapped causes, null tolerance). Full suites: 103/103 runtime unit (incl. 14/14 in Common.UnitTests including new tests) + 249/249 workflow integration pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(workflows-runtime): integrate CShells 0.0.15 lifecycle hooks (IDrainHandler + IShellInitializer) CShells 0.0.15 ships the lifecycle framework needed for first-class per-shell graceful shutdown — IDrainHandler / IShellInitializer / IShellLifecycleSubscriber. This commit bumps the package, migrates Elsa's existing usage of the removed 0.0.14 API, and registers the runtime drain orchestrator + pause-state initializer through the new primitives. Highlights: * `ElsaShellDrainHandler : IDrainHandler` — bridges per-shell drain into `IDrainOrchestrator.DrainAsync(DrainTrigger.ShellDeactivation, ct)`. Invoked by CShells when a shell enters `ShellLifecycleState.Draining`; the drain handler's CancellationToken is signalled when the per-shell deadline elapses, so the orchestrator's own deadline-bounded protocol nests cleanly. Coexists with the host-stop `DrainOrchestratorHostedService`; the orchestrator's `DrainAsync` is idempotent — second invocations log and skip. * `InitializePauseStateShellInitializer : IShellInitializer` — replaces the IStartupTask variant in shell-aware deployments. IShellInitializer fires on EVERY shell (re)activation, including reactivations after a reload — exactly what FR-028 requires. The IStartupTask remains for IModule consumers where there is no shell platform. Migrations (CShells 0.0.14 → 0.0.15 breaking changes): * `ActivateShellTenants`: was `IShellActivatedHandler` + `IShellDeactivatingHandler`, now `IShellInitializer` + `IDrainHandler`. * `MultitenancyFeature`: registrations updated to the new transient interface, `using CShells.Hosting` → `using CShells.Lifecycle`. * `Reload/Endpoint`, `ReloadAll/Endpoint`: `IShellManager` → `IShellRegistry`, `ReloadShellAsync` → `ReloadAsync` (returns `ReloadResult` with `Error`), `ReloadAllShellsAsync` → `ReloadActiveAsync` (returns `IReadOnlyList<ReloadResult>` with per-shell errors aggregated into 503). Build + restore: * `Directory.Packages.props`: all CShells.* packages bumped to 0.0.15. * `NuGet.Config`: added `cshells-feedz` source (https://f.feedz.io/sfmskywalker/cshells/nuget/index.json) and split the package-source-mapping pattern into `CShells` (exact) + `CShells.*` (prefix). Single-pattern `CShells*` does NOT match correctly under PackageSourceMapping. Tests: 103/103 runtime unit + 249/249 workflow integration pass on the new package version. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(workflows-runtime): apply PR #7424 review feedback Consolidates the architectural fixes asked for during /review: - Extract IWorkflowRuntimeAdminService to back the four /admin/workflow-runtime endpoints with a single domain service; thin Pause/Resume/Status/Force endpoints to delegating shells. - Remove StateChanged C# event from IQuiescenceSignal (Constitution VII: no external subscribers existed; mediator was suggested as the alternative if/when it's needed). - Promote InitializePersistedStateAsync to IQuiescenceSignal, dropping the concrete-cast in both InitializePauseStateStartupTask and InitializePauseStateShellInitializer. - Invert ingress-source DI to Lazy<IEnumerable<IIngressSource>> to break the cycle through IQuiescenceSignal; ingress adapters take the signal directly via primary constructor. - Replace Guid.NewGuid().ToString("N") with IIdentityGenerator in InterruptedLogExtensions and DrainOrchestrator. - Switch admin-audit timestamps to ISystemClock in WorkflowRuntimeAdminService. - Make GracefulShutdownOptions.StimulusQueueMaxDepthWhilePaused nullable (null = unlimited). - Rename RuntimeForceRequested → RuntimeForceDrainRequested. - Apply IsFatal exception filter to drain best-effort catches. - Rename IBurstRegistry.EnumerateActive → ListActiveBursts. - Refresh "Phase X" comments to user-story (USx) references. - Delete unused IngressAttributionExtensions, IngressSourceServiceCollectionExtensions, IngressSourceRegistrationOptions. - Migrate Elsa.Shells.Api.Tests to CShells 0.0.15 IShellRegistry / ReloadResult surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(graceful-shutdown): apply IsFatal filter to deadline-breach test catch Aligns the test scaffolding's swallow-everything catch with the project standard introduced in c00eee80c so the analyzer no longer flags the bare `catch` clause. The semantics are unchanged — non-fatal exceptions (OCE, TimeoutException, workflow exceptions) are still acceptable test outcomes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(workflows-runtime): close IngressSourceRegistry first-access race + spelling Replaces the non-atomic `_entries.Count > 0` early-return guard in EnsureMaterialized with a double-checked lock against a volatile `_materialized` flag, so concurrent first callers can no longer both iterate the source factory and crash one of them with a "Duplicate ingress source registration" InvalidOperationException. Adds a regression test that launches 16 readers behind a TaskCompletionSource gate and asserts every reader observes the full source set without throwing. Also flips the British spellings introduced in this PR's scope to American English (materialize/behavior) — project convention going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(constitution): require American English for new code (v1.0.1) Adds a "Spelling & language" bullet under principle III (Convention-Driven Design): every newly-introduced symbol, comment, identifier, error message, XML doc, commit message, and Speckit artifact uses American English. Established public API symbols (e.g. WorkflowSubStatus.Cancelled) are not renamed retroactively. PATCH bump because this is a clarification of an existing principle, not a new principle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add Greploop skill and workflow for GitLab, GitHub, and Perforce integration - Introduced Greploop, an iterative optimization and review workflow for GitLab MRs, GitHub PRs, and Perforce changelists. - Added API and GraphQL references for fetching and resolving review skill. * Remove GenerateWorkflowVariableAccessorsTests; redundant ExpandoObject type check in handlers * Potential fix for pull request finding 'CodeQL / Untrusted Checkout TOCTOU' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix(graceful-shutdown): apply PR #7424 review feedback round 2 Two P1 findings from Greptile: 1. DrainOrchestrator.ForceCancelActiveBurstsAsync was sequential — each burst was cancelled, awaited up to ForceCancelSettleTimeout (2 s), and persisted before the next burst's Cancel() ran. Total wall time was O(N × 2 s) and bursts 2..N kept executing at full speed during prior bursts' settle waits, defeating the intent of force-cancel under concurrency. Refactored to three phases: - Phase A — cancel every handle synchronously (cheap CTS.Cancel calls) so all runners observe cancellation simultaneously. - Phase B — await every Disposed signal in parallel under a single shared ForceCancelSettleTimeout. Total wall time bounded regardless of N. - Phase C — persist Interrupted for each handle sequentially (keeps DbContext usage single-threaded; per-handle work is small). Per-phase failures are caught with !ex.IsFatal() and logged so a single misbehaving handle doesn't abort the rest of the batch. 2. ShellFeatures/WorkflowRuntimeFeature.ConfigureServices was missing the IWorkflowRuntimeAdminService registration that Features/WorkflowRuntimeFeature already had. Any CShells deployment that includes the Pause / Resume / Status / Force admin endpoints (in Elsa.Workflows.Api) would throw InvalidOperationException at endpoint construction. Added the singleton alongside the other graceful-shutdown registrations with a comment pointing out the symmetry with the IModule path. 17/17 graceful-shutdown integration tests pass; 103/103 runtime unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Potential fix for pull request finding 'CodeQL / Untrusted Checkout TOCTOU' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix(ci): harden greploop.yml against CodeQL Actions findings CodeQL flagged 12 findings on .github/workflows/greploop.yml after the prior commit (c08183a3c) addressed an earlier round. Two distinct issue classes remain: 1. Code injection (× ~10): step-output values (steps.pr_head.outputs.head_sha / head_repo_owner / head_repo_name / head_ref) and inputs.pr_number were interpolated directly into shell `run:` blocks via `${{ ... }}`. Because PR author controls the branch name and the manual-dispatch input, those values can carry shell metacharacters. Standard fix: route every such interpolation through an `env:` block on the step, then reference $VAR inside the script. Applied to the Resolve, Resolve PR head metadata, and Checkout PR branch steps. 2. Untrusted Checkout TOCTOU + Checkout of untrusted code in trusted context: the workflow runs on `issue_comment` (a privileged trigger) and checks out PR-author code. Mitigations stacked here: - Author-association gate already restricts the trigger to OWNER / MEMBER / COLLABORATOR (existing). - Step-output values now travel via env vars (above). - Resolve step rejects pr_number that isn't ^[0-9]{1,10}$ — so downstream `gh pr view` and the prompt argument can't be hijacked. - Checkout step now validates HEAD_SHA matches ^[0-9a-f]{40}$ and the repo owner/name match ^[A-Za-z0-9_.-]+$ before either reaches a URL or a git command. - Existing TOCTOU guard preserved: re-fetch head SHA at checkout time and abort if it changed since the initial resolve. These match the canonical "Securing your GitHub Actions workflows" patterns recommended by CodeQL. No functional change to greploop's runtime behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(graceful-shutdown): drop [SingleNodeTask] from InitializePauseStateStartupTask Greptile P1: [SingleNodeTask] gates the task to a single cluster winner via distributed lock, but IQuiescenceSignal is a singleton scoped to each node's DI container — each node holds its own in-memory QuiescenceState. With the attribute, only the winning node restored the persisted pause; every other node started with QuiescenceReason.None and accepted new work, silently defeating PausePersistence = AcrossReactivations. Removed [SingleNodeTask] (and the corresponding using) so the task runs on every node. Expanded the doc <remarks> to call out the per-node requirement and point at the shell-aware counterpart (InitializePauseStateShellInitializer) which is correctly per-node by virtue of being an IShellInitializer. 17/17 graceful-shutdown integration tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Potential fix for pull request finding 'CodeQL / Code injection' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * chore(deps): bump CShells 0.0.15 → 0.0.17 0.0.17 ships our blueprint-aware-routing PR (valence-works/cshells#93) plus four follow-up fixes the maintainer added on top: - e56ebd8 — PreWarmShells removed entirely; ShellMiddleware now does cold-start endpoint matching (re-runs endpoint resolution after lazy activation so the very first request to a cold shell hits its endpoint). - cbe5ee2 — GetCandidateSnapshot returns a bounded ShellRouteCandidateSnapshot with accurate total counts; sensitive-data redaction in routing logs; DefaultShellRouteIndex implements IDisposable. - cd7d4f5 — Last-good snapshot served on rebuild failure (the deferred Copilot review concern); root-path fallback when path-by-name misses. - c3679d9 — Cold-start endpoint matching respects inline route constraints; path-name convention tightening; dead duplicate-detection cleanup. Net effect for elsa-core: - Cold blueprints serve their first request via lazy activation, with endpoints correctly resolved post-activation. - Reloaded shells re-activate and serve on the next matched request. - Non-name-mode routing keeps serving the previous snapshot during a transient blueprint-provider outage. - No need to call PreWarmShells from Elsa.ModularServer.Web — removed. The only API removal that touches elsa-core is PreWarmShells. No code references IShellRouteIndex / ShellRouteCriteria / GetCandidateSnapshot directly, so the API-shape changes in cbe5ee2 don't ripple here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(graceful-shutdown): persist Interrupted under non-drain bounded token Greptile P? finding on the prior force-cancel two-phase fix: Phase B's inner catch on OperationCanceledException ("drain CT fired — proceed to persist anyway") was a lie in practice. Phase C immediately passed the same already-cancelled drain token into PersistInterruptedAsync; the first DB call (instanceStore.FindAsync) observed the cancellation and threw OperationCanceledException; the outer non-fatal Exception filter swallowed it and only logged an error. Net effect: on host shutdown deadline breach, every burst after cancellation could fail to be persisted as Interrupted, leaving instances in an unrecovered executing state. Phase C now creates a per-handle CancellationTokenSource bounded to a new PersistInterruptedTimeout (5 s) that is NOT linked to the drain CT. Each persist gets up to 5 s to land the row update + forensic log entry even after the drain CT has fired. The bound prevents a stuck DB from hanging shutdown indefinitely (per-handle worst case is small; total Phase C upper bound is N × 5 s, but typical persists are millisecond scale). Comment expanded to call out why the persist token is independent of the drain token, so the rationale doesn't drift again. 17/17 graceful-shutdown integration tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(graceful-shutdown): extract PassiveIngressSource base class The three IIngressSource implementations that ship with this PR (InternalBookmarkQueueIngressSource, HttpTriggerIngressSource, ScheduledTriggerIngressSource) were ~25 lines each and ~22 lines of those were verbatim copies of each other: - ctor signature `(IQuiescenceSignal signal)` - `PauseTimeout => TimeSpan.FromMilliseconds(50)` - `CurrentState => signal.IsAcceptingNewWork ? Running : Paused` - `PauseAsync` / `ResumeAsync` returning `ValueTask.CompletedTask` The shared trait is that none of them does any work at pause time — the actual pause enforcement lives in another layer (`HttpWorkflowsMiddleware` short-circuits to 503, `BookmarkQueueProcessor` consults the signal at the top of each invocation, scheduled triggers dispatch through the bookmark queue and inherit that behaviour transitively). The IIngressSource adapter is purely diagnostic: it makes the source visible in `DrainOutcome.Sources` and the admin status endpoint. Extracted that pattern into `PassiveIngressSource` (abstract base in `Elsa.Workflows.Runtime.IngressSources`). Subclasses now provide only `Name`; `PauseTimeout` is `virtual` with a 50 ms default; everything else is fixed by the base. The three concretes drop from ~25 lines to ~12 lines each. The base's XML `<remarks>` calls out when to use it ("your component already cooperates with IQuiescenceSignal at its hot path") and when to implement IIngressSource directly ("the source owns concrete pause/resume behaviour — e.g. a message-queue consumer that calls Pause() on its underlying client"), so future contributors don't mis-extend the base for sources that need real work at pause time. No behavioural change. 18 graceful-shutdown integration + 39 runtime unit tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(graceful-shutdown): align IIngressSource name to singular The three IIngressSource names were inconsistent: http.trigger (singular) internal.bookmark-queue-worker (singular) scheduling.triggers (PLURAL — outlier) The plural slipped in when addressing Greptile's earlier comment to avoid `scheduling.cron` (which would imply Cron-only coverage). The right move was to pick a generic word and stay singular like the rest of the suite — the suite's mental model is "the X source", one instance per registry slot, regardless of how many triggers or items it dispatches internally. Renamed to `scheduling.trigger`. The `<remarks>` block keeps the "covers Cron, Timer, StartAt, Delay" explanation and now also explicitly notes the singular convention so future contributors don't re-pluralize. Zero test fallout — the literal "scheduling.triggers" only appeared in the source file itself. Tests of the other two sources all use singular forms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(runtime-admin): rename Force endpoint to ForceDrain "Force" alone is meaningless out of context — force what? — and it sits oddly next to the verb-named siblings Pause / Resume / Status. The matching admin service method is already IWorkflowRuntimeAdminService. ForceDrainAsync, so ForceDrain is the natural pair. Renamed: - src/modules/Elsa.Workflows.Api/Endpoints/RuntimeAdmin/Force/ → ForceDrain/ - namespace ...Endpoints.RuntimeAdmin.Force → ...ForceDrain - class ForceEndpoint → ForceDrainEndpoint - class ForceRequest → ForceDrainRequest - class ForceResponse → ForceDrainResponse - route POST /admin/workflow-runtime/force → /force-drain Zero external references — no tests, docs, or OpenAPI clients used the old symbols or the old route literal, so this is a contained pre-ship rename. Directory move went through `git mv` so commit history follows the file. 17/17 graceful-shutdown integration tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): correct formatting in checkout step within greploop.yml Adjusted indentation of environment variables in the checkout step for improved consistency and readability. * fix(ci): repair malformed Checkout repository step in greploop.yml The step accumulated stray env keys, an extra `uses:`, and bash commands that didn't belong inside it (line 65 onward), causing a YAML parse error on push. The valid structure has two distinct checkout steps: - Checkout repository : actions/checkout@v4 with fetch-depth: 0 - Checkout PR branch : env: + run: with SHA validation + git fetch + git checkout --detach The PR-branch step (line 78+) was already correct and unchanged. This fix restores the first step to its intended single-purpose shape (just checks out the workflow file's commit so the greploop skill is on disk before the run-greploop step uses it). No functional change to runtime behaviour or to the security posture established in the prior hardening commit (0db4ca23e). The PR-branch checkout still validates HEAD_SHA / HEAD_REPO_OWNER / HEAD_REPO_NAME shape before they reach a URL or git command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): remove literal `${{ }}` from greploop.yml comment GitHub Actions parses `${{ ... }}` workflow expressions across the entire YAML file, including inside `run:` script comments. The comment that explained the env-var hardening pattern contained the literal sequence `${{ }}` (with a space, intended as an English-language description), which the expression parser rejected as "An expression was expected" (line 81 col 14). Reworded the comment to describe the substitution form in prose without the literal token sequence. Functional behaviour unchanged. * refactor(graceful-shutdown): rename burst → execution cycle The graceful-shutdown work introduced "burst of execution" as a first-class domain concept. The term arrived without rationale and isn't standard in the workflow-engine domain. Renamed to "execution cycle" — reads more naturally as the loop-with-commit unit, is more idiomatic in workflow vocabulary, pairs cleanly with the existing WorkflowExecutionContext, and avoids collisions with Elsa's existing terms (Run, Execution, Dispatch, Invocation, Stimulus, Step). Renamed types - IBurstRegistry → IExecutionCycleRegistry - BurstRegistry → ExecutionCycleRegistry - BurstHandle → ExecutionCycleHandle - BurstTrackingMiddleware → ExecutionCycleTrackingMiddleware - BurstAwareCommitStateHandler → ExecutionCycleAwareCommitStateHandler Renamed members - BeginBurst → BeginCycle - ListActiveBursts → ListActiveCycles - BurstHandleKey constant + value → ExecutionCycleHandleKey - ActiveBurstCount (IQuiescenceSignal, RuntimeAdminStatus, StatusResponse) → ActiveExecutionCycleCount - WaitForBurstsAsync (private) → WaitForCyclesAsync - ForceCancelActiveBurstsAsync (priv) → ForceCancelActiveCyclesAsync - UseBurstTracking → UseExecutionCycleTracking - DrainOutcomeDto.BurstsForceCancelledCount → ExecutionCyclesForceCancelledCount - _burstRegistry / burstRegistry → _cycleRegistry / cycleRegistry Backwards-compatibility preservation (the only persisted JSON key) - WorkflowInterruptedPayload.BurstDuration property → ExecutionCycleDuration with [JsonPropertyName("BurstDuration")] so the persisted JSON wire key stays "BurstDuration" forever. Pre-merge testers' log records still deserialise correctly. The contract test on WorkflowInterruptedPayloadContractTests still asserts the wire key "BurstDuration" appears in the serialised JSON — confirms the guarantee is enforced. Other unstructured surfaces - WorkflowExecutionLogRecord.Message text "Workflow burst was force- cancelled..." now says "Workflow execution cycle was force-cancelled ..." for new records. Old rows keep their old text — purely cosmetic free-text field. - Structured log placeholder {BurstId} in DrainOrchestrator log lines → {ExecutionCycleId}. - Lowercase prose / XML doc comments updated throughout. Test renames - BurstRegistryTests → ExecutionCycleRegistryTests - BurstTrackingMiddlewareTests → ExecutionCycleTrackingMiddlewareTests - Test method names + DisplayName strings updated. Spec docs (specs/002-graceful-shutdown/) updated to match the new vocabulary; the historical task records in tasks.md keep the old names as-is to preserve the audit trail of what was originally built. Verification - dotnet build: clean across net8.0 / net9.0 / net10.0. - 103/103 Elsa.Workflows.Runtime.UnitTests pass. - 17/17 GracefulShutdown integration tests pass. - 4/4 WorkflowInterruptedPayloadContractTests pass — confirms the "BurstDuration" JSON wire-key preservation is intact. No changes to migrations or DB column names — confirmed via grep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): give greploop.yml gh-cli a repo context before checkout The "Resolve PR head metadata" step runs `gh pr view` before actions/checkout, so there is no `.git` directory and gh's "current repo" detection fails with `fatal: not a git repository`. The prior commits to this file masked the runtime failure because the workflow itself was YAML-invalid — once it became valid, the workflow_dispatch trigger surfaced this real-world execution bug. Set GH_REPO=${{ github.repository }} on both `gh pr view` steps. The gh CLI honours GH_REPO as an explicit repo override, so it no longer needs git context. Same fix on the "Checkout PR branch" validation call which also uses gh pr view before the manual fetch. The error reported as "Invalid workflow file: ... (Line 81 Col 14)" on PR #7424 was stale from commit ed580682a (which had the bad comment with literal `${{ }}`); commit 3a8ad0d08 fixed the YAML, but because greploop's `if:` condition only matches workflow_dispatch / issue_comment events, push events on later commits were skipped without re-running validation, so the GitHub UI kept showing the old error. A workflow_dispatch run on the current SHA now passes validation and reaches "Resolve PR head metadata", which is what this commit fixes. * fix(graceful-shutdown): wire IngressPauseTimeout option to drain orchestrator GracefulShutdownOptions.IngressPauseTimeout was documented as "Default per-ingress-source pause timeout" but DrainOrchestrator.PauseOneSourceAsync read source.PauseTimeout directly and never consulted the option. The configured value was silently ignored — operators who set GracefulShutdownOptions:IngressPauseTimeout = 10s were getting whatever each source's hardcoded value was (50 ms for the three PassiveIngressSource subclasses we ship), with no way to tune it globally. Precedence (per the spec's intent of "overridable at registration and by configuration"): 1. Per-source positive value wins (source.PauseTimeout > Zero). 2. Otherwise fall back to the configured GracefulShutdownOptions. IngressPauseTimeout default. 3. Resolved value is capped at the overall drain deadline so a single misbehaving source cannot exceed the host's shutdown budget. 4. 1 ms safety floor remains so a misconfigured zero default still produces a non-zero CancelAfter. Changes: - DrainOrchestrator.PauseOneSourceAsync — adds the precedence above with a comment block explaining each step. - IIngressSource.PauseTimeout — XML doc clarifies the Zero-defers-to- config semantics. - GracefulShutdownOptions.IngressPauseTimeout — XML doc says it's the fallback when the source returns Zero; <remarks> spells out the precedence and the overall-deadline cap. - PassiveIngressSource.PauseTimeout — virtual property now returns Zero (was 50 ms). The three shipped subclasses (HttpTriggerIngressSource, ScheduledTriggerIngressSource, InternalBookmarkQueueIngressSource) consequently defer to the configured default — flipping the wire-up bug from "configured value silently ignored" to "configured value honoured by default for passive sources". Passive subclasses that want a specific value can still override. 103/103 runtime unit + 17/17 graceful-shutdown integration tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(graceful-shutdown): rename IInterruptedRecoveryScan → IInterruptedRecoveryScanner The interface had a single verb-method (`ScanAndRequeueAsync`) and its XML doc described what it *does* ("Scans the workflow instance store for instances..."). That's an agent role — a scanner that performs a scan — but the noun-shaped name `IInterruptedRecoveryScan` read as "the scan itself", which is misleading because the scan results / event are not first-class types in the codebase. Renamed to `IInterruptedRecoveryScanner` / `InterruptedRecoveryScanner` to match the existing `-er` convention in this codebase (Restarter, Generator, Resolver, etc.). The method stays `ScanAndRequeueAsync` — the scanner *performs* a scan-and-requeue. Also renamed the constructor parameter `scan` → `scanner` in RecoverInterruptedWorkflowsStartupTask, and the local variable `scan` → `scanner` in InterruptedRecoveryIntegrationTests, so the "scanner does the scan" mental model is consistent throughout. Surface impact (all internal — no API or persistence touch points): - 2 source files renamed via git mv (interface + implementation) - 1 test file renamed (InterruptedRecoveryScanTests → ScannerTests) - DI registrations in both Features/ and ShellFeatures/ WorkflowRuntimeFeature - 1 startup-task constructor parameter - Spec doc references under specs/002-graceful-shutdown/ 103/103 runtime unit + 17/17 graceful-shutdown integration tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(graceful-shutdown): extract DrainTriggerExecutor ElsaShellDrainHandler (CShells IDrainHandler) and DrainOrchestratorHostedService (.NET IHostedService.StopAsync) inlined near-identical try/catch/log shapes around IDrainOrchestrator.DrainAsync: - call DrainAsync(<trigger>, ct) - branch on outcome: DeadlineExceeded/AbortedByUnhandledException → Warning, otherwise Information - catch InvalidOperationException (parallel-drain rejected by the orchestrator) → log Information and swallow The two had already drifted: host-stop's success log omitted the paused/waited durations the shell-handler version included, and the "skipped" message disagreed on the trigger label ("Host-stop drain skipped" vs "Shell drain skipped"). Centralised the shape in a small internal static helper so the two — and any future trigger source — stay uniform. Both call sites collapse to a single line. Net diff drops 22 lines from the two consumers and adds a 25-line helper that they both delegate to. The unified log copy now consistently includes paused/waited durations on the success path and uses the caller-supplied contextLabel ("Shell drain", "Graceful drain") in all three messages so operators can attribute log entries by trigger source. Files: - src/modules/Elsa.Workflows.Runtime/Services/DrainTriggerExecutor.cs (new) - src/modules/Elsa.Workflows.Runtime/Lifecycle/ElsaShellDrainHandler.cs - src/modules/Elsa.Workflows.Runtime/HostedServices/DrainOrchestratorHostedService.cs 103/103 runtime unit + 17/17 graceful-shutdown integration tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(workflows-runtime): drop redundant DrainOrchestratorHostedService from CShells path In CShells deployments, host stop already drives drain via CShellsStartupHostedService → IDrainHandler → ElsaShellDrainHandler, scoped per shell (FR-027). The additional .AddHostedService<DrainOrchestratorHostedService>() in ShellFeatures/WorkflowRuntimeFeature was firing a second non-force DrainAsync that the orchestrator rejected with InvalidOperationException — silently swallowed by DrainTriggerExecutor, but logged on every host stop and semantically muddled (IHostedService is host-level, not per-shell). Keep the registration on the IModule path (Features/WorkflowRuntimeFeature) where there is no shell platform and host-stop is the only available drain trigger. Update ElsaShellDrainHandler XML docs to reflect the now-clean single-trigger model in CShells. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(workflows-runtime): always dispose ExecutionCycleHandle in tracking middleware Previously the success path relied on ExecutionCycleAwareCommitStateHandler to dispose the handle after the runner's commit completed. If a custom dispatcher or test double exited the pipeline without invoking commit (by design or by accident), the handle stayed registered, IExecutionCycleRegistry.ActiveCount never reached zero, and drain spun in WaitForExecutionCyclesAsync until the deadline fired — incorrectly force-cancelling instances that had already finished cleanly. Collapse the existing try/catch(rethrow) into try/finally so the middleware itself disposes the handle for both exception and commit-elided paths. Disposal remains idempotent via the ExecutionCycleHandle._disposed Interlocked guard, so the normal-path dispose by ExecutionCycleAwareCommitStateHandler is a harmless no-op. Adds an integration regression test that drives the middleware with a stub Next that returns without invoking commit and asserts ActiveCount returns to zero. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(workflows-runtime): serialize QuiescenceSignal pause-state persistence Both PauseAsync and ResumeAsync used to release the inner lock before issuing the persistence I/O. A rapid Pause → Resume sequence could leave the persisted state inconsistent: PauseAsync's slow SaveAsync could land AFTER ResumeAsync's DeleteAsync, leaving the key present in the store while in-memory state was None. On host restart, InitializePersistedStateAsync would find the stale key and start the runtime in the paused state the operator had already cancelled. Introduce a dedicated SemaphoreSlim that serializes persistence I/O, with each I/O re-reading the live in-memory state inside the semaphore. N racing Pause/Resume calls now produce N serialized writes, each reflecting the most recent in-memory transition — so the final persisted state always matches final in-memory state. Adds a regression test that gates SaveAsync, races a Resume behind it, and asserts the store is empty after both complete. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(workflows-runtime): trim verbose comment in ExecutionCycleTrackingMiddleware finally block Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(workflows-runtime): use 'using var' for ExecutionCycleHandle in tracking middleware Replace the explicit try/finally that only existed to call handle.Dispose() with a `using var` declaration — identical semantics (compiler-emitted finally with idempotent dispose), more idiomatic. The regression test HandleReleasedWhenCommitIsElided continues to validate the leak-free property. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(workflows-runtime,api): proper 409 conflict shape + shell-scoped pause-persistence key ForceDrain endpoint: the 409 path returned `new ForceDrainResponse()` whose `Outcome` was null at runtime despite the `= null!` annotation, so any strongly-typed client deserializing the conflict body and reading `Outcome.OverallResult` got an NRE. Switch to the existing `ConflictResponse` shape with `Code = "DrainInProgress"` and the current runtime status. Routed via HttpContext.Response.WriteAsJsonAsync because Send.ResponseAsync is constrained to the endpoint's TResponse and cannot send a sibling DTO. QuiescenceSignal persistence key: the DI-registered `IQuiescenceSignal` was constructed with `shellName = null` (DI doesn't inject `string?` defaults), so every shell shared the key `elsa.quiescence.pause.default`. In a CShells multi-shell deployment under PausePersistencePolicy.AcrossReactivations this caused cross-shell contamination — pausing shell A would re-pause shell B on its next activation. Replace the simple AddSingleton<IQuiescenceSignal,...> registration in ShellFeatures/WorkflowRuntimeFeature with a factory that injects `CShells.ShellSettings` and forwards `Settings.Id` as the shell name. The IModule registration is unchanged (no shell platform; null shellName remains correct there). Adds a unit regression test that two QuiescenceSignal instances with different shellNames write to disjoint persistence keys and never to the legacy "default" key. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(quiescence): use TryGetValue for ContainsKey+indexer assertions Combines existence check and value retrieval into a single dictionary lookup, addressing the code-quality bot's repeated suggestion. No behavior change — both PauseWritesKey and PersistenceKeyIncludesShellName still assert the same keys exist with the same content. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Update specs/002-graceful-shutdown/contracts/admin-endpoints.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(workflows-runtime): decouple QuiescenceSignal persistence from caller cancellation PersistAsync used to forward the caller's CancellationToken to both _persistenceMutex.WaitAsync and the store I/O. If an HTTP request was cancelled between the in-memory transition (already committed under _sync) and the persistence call, the I/O was silently skipped — leaving AdministrativePause set in memory with no persisted record. The idempotent fast-path on subsequent PauseAsync calls (transitioned == false) meant no retry would happen, so on host restart InitializePersistedStateAsync would find no key and the runtime would come back unpaused, defeating PausePersistencePolicy.AcrossReactivations. Drop the parameter from PersistAsync entirely; use CancellationToken.None for both the semaphore wait and the store I/O. The in-memory transition is already committed by the time PersistAsync runs, so persistence must complete to keep the store consistent with memory. The public PauseAsync/ResumeAsync methods still accept a CancellationToken (interface contract) — it just no longer reaches the persistence layer. Adds a regression test that calls PauseAsync with a pre-cancelled token and asserts both in-memory pause and the persisted key land correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(workflows-runtime,api,docs): apply Copilot review feedback batch Code: - DrainOrchestrator.TryForceStopAsync now bounds force-stop with the *remaining* drain budget (deadlineAt - now), not the full overall TimeSpan. A per-source pause that already burned the shutdown window can no longer get another full deadline's worth of force-stop runway. - DrainOrchestrator catch filter narrowed: drop `ex is not InvalidOperationException` exclusion. The "drain already in progress / completed" IOEs are thrown outside the protocol's try block, so they bubble out without entering this handler. Any IOE that lands here is incidental (e.g., from a store inside the drain) and should now be captured into the outcome rather than escaping the whole drain. - ResumeEndpoint 409 path now returns the discriminated ConflictResponse shape (matching ForceDrain) instead of a plain StatusResponse. Routed via HttpContext.Response.WriteAsJsonAsync because Send.ResponseAsync is constrained to TResponse. - Conflict codes aligned to kebab-case across both endpoints to match the contract spec (`runtime-draining` and `drain-in-progress`). Spelling sweep — American English per constitution v1.0.1 III: - DrainOrchestrator.cs: "serialised" → "serialized" - WorkflowInterruptedPayload.cs: "serialised" / "deserialise" → "serialized" / "deserialize" - PassiveIngressSource.cs: "behaviour" → "behavior" - DeadlineBreachEndToEndTests.cs: "serialisable" → "serializable" - specs/002-graceful-shutdown/quickstart.md: "behaviour" → "behavior" - specs/002-graceful-shutdown/checklists/requirements.md: "behaviour" → "behavior" Doc/contract alignment: - quickstart.md: force route corrected from /force to /force-drain. - quiescence-signal.md: removed StateChanged event from contract (interface doesn't define it); corrected persistence section to describe InitializePersistedStateAsync via shell initializer / startup task rather than constructor read; added the per-shell key discriminator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(workflows-runtime): correct DI lifetimes for ExecutionCycleTrackingMiddleware and WorkflowRuntimeAdminService Two strict-DI-validation failures surfaced in tests using BuildServiceProvider with validate-on-build: 1. ExecutionCycleTrackingMiddleware was registered as AddSingleton<>, but its constructor takes WorkflowMiddlewareDelegate next — supplied by the workflow execution pipeline builder via UseMiddleware<>(), not from DI. The registration was both unused (no consumer resolves it through the container) and broken (DI fails to construct it because next is unregistered). Removing both registrations. 2. IWorkflowRuntimeAdminService was registered as AddSingleton<> but depends on the scoped INotificationSender (mediator) — captive-dependency violation. All consumers (Pause/Resume/ Status/ForceDrain endpoints) are FastEndpoints, which are scoped per request, so AddScoped is the correct alignment. The other deps (IQuiescenceSignal / IIngressSourceRegistry / IDrainOrchestrator / ISystemClock) are singletons and resolve fine from a scoped consumer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(workflows-runtime): restore commit-handler-only success disposal + true Cancel idempotency Two issues raised by Copilot's latest review on commit 32a9c0519: 1. ExecutionCycleTrackingMiddleware was disposing the handle at the end of InvokeAsync (via `using var`), but WorkflowRunner runs commit AFTER the pipeline returns (WorkflowRunner.cs:235). That meant the handle was disposed BEFORE the runner's terminal commit, and the drain orchestrator's `await handle.Disposed` would unblock too early — reintroducing the runner-clobber race the original design protected against (see ExecutionCycleAwareCommitStateHandler XML doc). Revert to the original shape: only dispose on exception path. ExecutionCycleAwareCommitStateHandler remains the SOLE success-path disposer, running in its finally block AFTER the inner commit lands. The earlier "leak when commit is elided" concern was a non-issue in production (the standard runner always commits); the buggy `HandleReleasedWhenCommitIsElided` test that specified the wrong contract is removed. The existing `ActiveCountReturnsToZero` test (which uses the real runner end-to-end) already verifies success-path disposal. 2. ExecutionCycleHandle.Cancel() was documented as idempotent but only short-circuited via the _disposed flag. Repeated Cancel() calls before Dispose could trigger the cancel callback multiple times — easy to accidentally fire non-idempotent cancellation side effects more than once. Add an Interlocked _cancelled guard so callback + CTS cancellation run at most once. Existing test that documented the leaky behavior is updated to assert the now-truly-idempotent contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Update logging levels and remove unused features in appsettings files * refactor(workflows-runtime): improve graceful shutdown options handling and cleanup solution Refactor the handling of `GracefulShutdownOptions` to ensure options are applied correctly without directly invoking the delegate. Update DI registrations to use appropriate lifetimes and remove redundant wrapper services. Additionally, clean up the solution by removing unused projects and documentation folders. * feat(identity, workflows-runtime): add validation for identity and graceful shutdown options Introduce validation capabilities for `IdentityTokenOptions` and `GracefulShutdownOptions`. Implement extension methods for option validation, enhance service registration, and add unit tests to ensure configurations are validated at startup. Update solution to include new unit test projects. * update(docs): clarify shutdown log message expectations and levels in quickstart.md Optimize explanation of expected log message sequence during graceful shutdown and specify logging levels. * docs: amend constitution to v1.1.0 (SRP, DRY, KISS, conciseness under Principle VII) * refactor(multitenancy): rename and restructure TenantTaskManager to TenantTaskLifecycleCoordinator Rename `TenantTaskManager` to `TenantTaskLifecycleCoordinator` and relocate to a new directory structure, enhancing code organization and test consistency. Retain functional behaviors with no logic alterations. Update unit tests to reflect the naming changes, ensuring consistency with the refactored code structure. * Update logging levels and dependencies - Set default logging level to Debug in appsettings.Development.json - Add missing using directives for Elsa workflows management and runtime features - Update CShells package versions to 0.0.18-preview.104 --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
25 KiB
Feature Specification: Graceful Shutdown for the Workflow Runtime
Feature Branch: 002-graceful-shutdown
Created: 2026-04-23
Status: Draft
Input: User description: "When the host application receives a stop signal, ongoing workflow executions should be given a chance to finish their current execution cycle of execution before the process exits. Event ingress points (message consumers, schedulers, HTTP triggers, internal pollers) must be able to consult a runtime quiescence signal and stop feeding new work to the engine while drain is in progress. An administrator must also be able to place the runtime into a reversible paused state for operational maintenance without stopping the host. The design must interoperate with the platform's shell lifecycle so that a shell being retired or reloaded behaves analogously to a host shutdown, scoped to that shell."
Clarifications
Session 2026-04-23
- Q: What should happen to an active execution cycle of execution when drain begins — break at the next cancellation check, or let it run to its next natural suspension point bounded by the drain deadline? → A: Let the current execution cycle complete; the drain deadline is the outer bound. Individual activities that call external systems must still honour the cancellation token passed to them. Only a deadline breach or operator force forces cancellation mid-execution cycle.
- Q: Where does pause apply in a multi-shell deployment — globally, per shell, or both? → A: Container-scoped. The runtime quiescence signal is a service registered in the same container as the workflow runtime. Hosts that run a single runtime (no shell platform) get a global pause; hosts that run multiple shells get per-shell pause automatically. No cross-shell coordination is built.
- Q: Should the durable stimulus queue keep accepting new writes while the runtime is paused, or should writes be rejected? → A: Keep accepting writes. Only the processor that drains the queue is paused. A policy-level back-pressure threshold MAY trigger a degraded readiness signal and optionally reject writes, but the default is to buffer so upstream transports are not forced into redelivery loops.
- Q: How should the system behave when an individual ingress source fails to acknowledge a pause request (throws, hangs, or keeps delivering)? → A: Each ingress source has its own bounded pause timeout, independent of the drain deadline. A source that does not complete its pause within its timeout is recorded in a
PauseFailedstate and surfaced via the status API. Drain does not block on any single failing source. If a source implements an optional force-stop capability, it is invoked as escalation. The runtime also tracks which source started each execution cycle so that a source claimingPausedwhile still delivering is detectable. - Q: How should force-cancelled workflows (deadline breach or operator force) be represented so they can be recovered without being confused with ungraceful crashes? → A: Introduce a new
Interruptedsub-status, orthogonal toSuspended. Set by the drain path before the process exits. On shell activation the runtime performs a scoped scan forInterruptedinstances and requeues them immediately, bypassing the existing crash-recovery timeout. Ungraceful crashes continue to be recovered by the existing timeout-based path; the two mechanisms coexist. - Q: Where should the forensic record of an interruption live? → A: In the existing per-instance execution log, using a new
WorkflowInterruptedevent name with a typed payload describing reason, shell generation identifier, and last active activity. No new storage entity. A separate spec will address broadening the execution log to cover lifecycle transitions more generally.
User Scenarios & Testing (mandatory)
User Story 1 - Host receives a stop signal during active workflow execution (Priority: P1)
An operator deploys a new version of the host application, or an orchestrator rolls a pod, and the process receives a stop signal while workflow instances are mid-execution. Those instances must be given the opportunity to finish their current execution cycle of execution and persist at a clean boundary, rather than being aborted mid-activity.
Why this priority: Without this, every deployment risks leaving workflows in inconsistent states. This is the core safety story that justifies the feature. Any other user story is refinement on top.
Independent Test: Start a long-running workflow whose execution cycle takes several seconds to complete. Issue a stop signal to the host. Verify that the host does not exit until the execution cycle has completed and the instance is persisted at its natural suspension point, and that no new execution cycles were dispatched after the stop signal.
Acceptance Scenarios:
- Given a workflow instance is executing a execution cycle of activities, When the host receives a stop signal, Then the execution cycle runs to completion and the instance reaches a persisted suspension, completion, or fault state before the process exits.
- Given external event ingress is actively delivering messages into the runtime, When the host receives a stop signal, Then ingress sources pause before the drain waits for active execution cycles, and no new execution cycles start after the pause point.
- Given an active execution cycle has not completed by the drain deadline, When the deadline elapses, Then the affected instances are cancelled, marked with a distinguishable interruption state, and forensic details are recorded before the process exits.
- Given an individual ingress source fails to acknowledge its pause request, When the drain proceeds, Then the failing source is surfaced in the status output and drain is not blocked by it; the runtime still completes within the drain deadline.
- Given the host platform exposes shell lifecycle hooks, When a single shell is retired or reloaded, Then that shell drains in the same manner as a host shutdown, and other shells on the same host are unaffected.
User Story 2 - Administrator pauses the runtime for maintenance (Priority: P2)
An administrator needs to place the workflow runtime into a reversible paused state — for example, to drain a broker queue for migration, to run a potentially disruptive maintenance script, or to investigate a production incident — without stopping the host process.
Why this priority: Pause is an operational capability layered on the quiescence machinery introduced in P1. It is valuable but the host-shutdown story ships before it.
Independent Test: Invoke the pause API against a running runtime. Verify that ingress sources transition to Paused, that workflow instances resulting from already-in-flight execution cycles continue to completion, that no new execution cycles begin, and that invoking the resume API restores normal operation including drainage of any stimuli buffered during the pause.
Acceptance Scenarios:
- Given the runtime is in normal operation, When an authorised administrator invokes the pause action, Then every ingress source transitions to
Paused(orPauseFailedwith a visible error), already-running execution cycles continue to completion, and no new execution cycles begin. - Given the runtime is paused, When an authorised administrator invokes the resume action, Then ingress sources transition back to
Running, any buffered stimuli are drained, and normal operation resumes. - Given the runtime is paused, When an unauthorised caller attempts to pause, resume, or query status, Then the request is rejected with an authorisation failure outcome.
- Given the runtime is paused, When the status action is invoked, Then the response identifies the overall quiescence state, the reason (administrative pause), per-ingress-source state, and the count of active execution cycles.
- Given the host runs multiple shells, When an administrator pauses one shell's runtime, Then the other shells continue normal operation and the paused shell's state is independently reported.
- Given pause is requested while a drain is already in progress, When the drain completes, Then the runtime does not auto-transition out of pause; resume remains an explicit operator action.
- Given pause state is configured to persist across shell activations, When a shell is reloaded while paused, Then the new shell generation starts in the paused state so that stimuli accumulated during the reload are not dispatched until resume.
User Story 3 - Interrupted workflows recover automatically on next activation (Priority: P3)
When a drain exceeds its deadline or an operator issues a force-stop, workflows that were mid-execution cycle at that moment are force-cancelled. On the next host start (or next shell activation), these instances should recover automatically, without operator intervention, and should be distinguishable in dashboards and metrics from workflows that died in an ungraceful crash.
Why this priority: The runtime already provides timeout-based recovery for ungracefully terminated workflows, so P1 and P2 can ship without this. P3 is a refinement that closes the latency gap (recovery in seconds on startup rather than minutes of staleness detection) and improves operational observability.
Independent Test: Force a drain deadline breach on an active execution cycle. Verify that the affected instance is marked with the interrupted sub-status, that a forensic entry is written to the per-instance execution log, and that on next shell activation the instance is requeued and resumed without waiting for the existing timeout-based recovery cadence.
Acceptance Scenarios:
- Given a execution cycle is force-cancelled during drain, When persistence completes before process exit, Then the instance carries the interrupted sub-status and an execution log entry describing the interruption.
- Given a set of instances in the interrupted sub-status, When a shell activates, Then each instance is requeued for execution within the shell's activation path and does not wait for the periodic recovery task.
- Given an instance died because the host crashed ungracefully (no chance to mark it interrupted), When the existing timeout-based recovery task next runs, Then that instance is recovered by the existing mechanism exactly as it is today.
- Given an interrupted instance resumes successfully, When an operator queries its audit history, Then the forensic interruption record remains visible alongside any subsequent events.
- Given dashboards filter workflows by sub-status, When an operator views the interrupted bucket, Then they see only instances that were force-cancelled by graceful shutdown, not instances that were user-cancelled, faulted, or crash-recovered.
Edge Cases
- A execution cycle schedules additional activities in a tight loop, never naturally reaching suspension. Drain deadline bounds the wait; force-cancel applies.
- An ingress source acknowledges its pause but continues to deliver messages due to a buggy implementation. The runtime detects this via per-execution cycle ingress attribution and flips the source's recorded state to
PauseFailed. - Pause is requested while another pause request is in flight. The second request is a no-op with the same reason; the runtime does not create parallel pause operations.
- Resume is requested while drain is in progress. The request is rejected until drain completes; resume is only meaningful for administrative pause.
- A shell is retired before its runtime has ever transitioned into normal operation. No drain is necessary; termination proceeds.
- The durable stimulus queue exceeds its configured back-pressure threshold while paused. The runtime surfaces a degraded readiness signal and, if configured, rejects new writes with a typed error that transports can translate into their own back-pressure primitives.
- A third-party ingress source is registered after application startup. Out of scope: dynamic registration is not supported; changes require a shell activation.
- Multiple operators invoke pause and resume actions concurrently. Requests serialise at the runtime and each receives a deterministic outcome with the post-request state.
- A force-stop request arrives for an ingress source that does not implement the force-stop capability. The source is left in
PauseFailed; the drain does not block. - The persistence layer is unavailable at the moment of drain. Execution cycles that complete and cannot be persisted transition into the interrupted sub-status by policy, and the failure is surfaced in the drain outcome.
Requirements (mandatory)
Functional Requirements
Quiescence model
- FR-001: The runtime MUST expose a single quiescence signal, readable synchronously by any collaborator, that indicates whether the runtime is currently accepting new work and, if not, the reason (drain, administrative pause, or both).
- FR-002: Drain state MUST be forward-only within a single runtime generation. Once entered, the runtime cannot return to normal operation without a new generation (host restart or shell reactivation).
- FR-003: Administrative pause state MUST be reversible via an explicit resume action within the same runtime generation.
- FR-004: Drain and administrative pause MUST be composable: a runtime may be in both states simultaneously, and the resume action MUST only clear the administrative pause component.
- FR-005: The quiescence signal MUST be scoped to the container in which the workflow runtime is registered. In single-runtime hosts this is global; in multi-shell hosts each shell has its own independent quiescence state.
Ingress source contract
- FR-006: The runtime MUST define a contract by which any component that injects external events into the workflow engine — including first-party message consumers, first-party schedulers, first-party HTTP trigger handlers, internal durable queue processors, internal recurring tasks that enqueue work, and third-party modules — registers itself as an ingress source.
- FR-007: Each ingress source MUST support, at minimum, a pause operation, a resume operation, and a state query.
- FR-008: Each ingress source MAY declare an optional force-stop capability used by the runtime as escalation when a pause request cannot be completed within its configured timeout.
- FR-009: Each ingress source MUST have a configurable pause timeout independent of the overall drain deadline; the default MUST be documented and overridable at registration time.
- FR-010: Pause and resume operations on an ingress source MUST be idempotent and safe under concurrent invocation.
- FR-011: An ingress source MUST transition through a documented state machine (
Running,Pausing,Paused,PauseFailed,Resuming,ResumeFailed). Transitions MUST be observable via the status query. - FR-012: A pause attempt that throws, hangs past the per-source timeout, or is otherwise not completed MUST leave the source in
PauseFailedwith a captured error; it MUST NOT block the overall drain or pause operation. - FR-013: When the runtime enters drain or administrative pause, it MUST invoke pause on all registered ingress sources in parallel, honour each source's individual timeout, and record failures without aborting the overall transition.
Execution cycle-of-execution lifecycle
- FR-014: The runtime MUST maintain a registry of active execution cycles of execution, including which ingress source initiated each execution cycle where that attribution is available.
- FR-015: During drain, active execution cycles MUST be permitted to run to their next natural persistence boundary (suspension, completion, or fault) within the drain deadline.
- FR-016: The drain process MUST wait for the active-execution cycle count to reach zero before allowing the host to exit, bounded by the drain deadline.
- FR-017: When the drain deadline elapses or an operator-initiated force is issued, the runtime MUST cancel outstanding execution cycles, persist the affected instances in the interrupted sub-status, record forensic detail, and proceed to termination.
- FR-018: A execution cycle started by an ingress source that claims to be in the
Pausedstate MUST cause the runtime to flip that source's recorded state toPauseFailedand surface the inconsistency in the status output.
Interruption and recovery
- FR-019: The workflow sub-status model MUST include an interrupted value distinct from suspended, cancelled, and faulted. It MUST represent "last execution cycle was force-cancelled by the runtime; the instance remains resumable."
- FR-020: Every force-cancelled execution cycle MUST record a forensic entry in the existing per-instance execution log, using a stable event name and a typed payload that includes interruption time, reason, shell generation identifier, and the identifier of the last active activity.
- FR-021: On shell activation, the runtime MUST scan for instances in the interrupted sub-status scoped to its container, and requeue each one for execution without waiting for the periodic crash-recovery cadence.
- FR-022: The existing timeout-based crash-recovery mechanism MUST continue to operate unchanged as a safety net for ungraceful terminations that had no opportunity to mark instances as interrupted.
- FR-023: An interrupted instance that resumes MUST transition out of the interrupted sub-status through the normal execution path; the execution log entry describing its interruption MUST remain.
Durable stimulus queue
- FR-024: The durable stimulus queue MUST continue to accept new writes while the runtime is in administrative pause or drain; only its processor is subject to pause.
- FR-025: The runtime MUST support a configurable maximum depth for the stimulus queue while paused. When exceeded, the runtime MUST surface a degraded readiness signal.
- FR-026: When the configured maximum depth is exceeded and the policy is set to reject writes, the runtime MUST reject new stimuli with a typed error that upstream transports can translate into their own back-pressure primitive. The default policy MUST be to buffer.
Shell lifecycle integration
- FR-027: When the host platform exposes shell lifecycle transitions, the runtime MUST translate a shell moving into its deactivation phase into a drain of that shell's runtime, scoped so that sibling shells are unaffected.
- FR-028: Pause state MUST survive shell reactivation when the persistence policy so dictates, so that a runtime reloaded while paused re-enters pause immediately rather than briefly dispatching accumulated stimuli.
- FR-029: The runtime MUST continue writing its per-host liveness signal throughout drain, so that the timeout-based crash-recovery path does not false-positive on instances being gracefully handled.
Administrative interface
- FR-030: The runtime MUST expose an authenticated and authorised action to pause its quiescence state, with an optional caller-provided reason.
- FR-031: The runtime MUST expose an authenticated and authorised action to resume its quiescence state.
- FR-032: The runtime MUST expose an authenticated and authorised action to query status, returning the composite quiescence state and reason(s), per-ingress-source state and last error, and the active-execution cycle count.
- FR-033: Pause, resume, and force-stop actions MUST be idempotent and MUST return the post-request state rather than a transient acknowledgement.
- FR-034: Administrative pause, resume, and operator-force events MUST be audited.
Configuration
- FR-035: The runtime MUST expose configuration for: overall drain deadline, per-ingress-source default pause timeout, stimulus-queue depth threshold while paused, stimulus-queue overflow policy (buffer or reject), and pause-persistence policy (session-scoped or across shell reactivations).
Key Entities (include if feature involves data)
- Quiescence State: The composite signal describing whether the runtime is accepting new work and why not. Exposes whether drain, administrative pause, or both are active.
- Ingress Source: An adapter through which external events enter the workflow engine. Has a name, a state (
Running,Pausing,Paused,PauseFailed,Resuming,ResumeFailed), a configured pause timeout, and an optional force-stop capability. - Execution cycle Handle: A handle representing one in-flight execution cycle of execution. Carries the initiating workflow instance identifier and, where available, the name of the ingress source that caused the execution cycle to start.
- Interrupted Sub-Status: A new value in the existing workflow sub-status enumeration, denoting an instance whose last execution cycle was force-cancelled by the runtime and is awaiting recovery.
- Interruption Record: A new category of entry in the existing per-instance execution log, carrying a stable event name and a typed payload describing interruption time, reason, shell generation identifier, and last active activity identifier.
- Drain Outcome: The structured result of a completed drain, including overall status, elapsed time for each phase, per-source final state with any captured error, and the count of execution cycles force-cancelled at the deadline.
Assumptions
- The host platform provides a stop signal that can be observed by the runtime.
- The host platform's shell lifecycle, where available, provides forward-only deactivation transitions with a cooperative deadline model. The runtime plugs into that model rather than inventing a parallel one.
- Workflow activities that perform external I/O already observe the cancellation token passed to them, so that force-cancel at drain deadline propagates through in-flight calls. Activities that do not observe cancellation are a per-activity contract issue, not a graceful-shutdown issue.
- Only privileged operational users or automation are expected to invoke pause, resume, and force actions.
- A single runtime generation corresponds to a single logical container — either the host process or one shell activation.
- Deployments that run a distributed cluster tolerate individual nodes refusing new work while draining; cross-node handoff of in-memory execution cycle state is explicitly out of scope.
Dependencies
- The host platform's shell lifecycle model (where deployments use it) for drain trigger propagation and forward-only state transitions with deadline policy.
- The existing per-instance execution log and its store abstraction, which carry the new interruption event records.
- The existing workflow dispatcher, which is the resume mechanism invoked by the shell-activation recovery scan and the periodic crash-recovery task.
- The existing workflow sub-status enumeration, extended with the new interrupted value.
- The existing host-level liveness heartbeat service, whose lifecycle must continue throughout drain so that the timeout-based crash recovery does not false-positive.
Success Criteria (mandatory)
Measurable Outcomes
- SC-001: In validation scenarios, 100% of active execution cycles that can complete within the configured drain deadline do so before the host exits, and the corresponding workflow instances reach a persisted suspension, completion, or fault state.
- SC-002: In validation scenarios, 100% of execution cycles that cannot complete within the drain deadline result in instances marked with the interrupted sub-status and a corresponding forensic entry in the per-instance execution log.
- SC-003: After a drain with interruptions, 100% of interrupted instances are requeued for execution on the next shell activation without waiting for the periodic crash-recovery cadence.
- SC-004: An ingress source that fails to pause within its configured timeout does not extend the overall drain duration beyond its deadline, and its failure is reported in the drain outcome.
- SC-005: Administrative pause stops all registered ingress sources from initiating new execution cycles within their configured pause timeouts, and administrative resume restores normal operation with accumulated stimuli dispatched in order.
- SC-006: Pausing one shell in a multi-shell deployment does not affect the active-execution cycle throughput of any other shell on the same host.
- SC-007: 100% of pause, resume, force-stop, and status actions are idempotent: repeated invocations with the same arguments converge on the same observable state and produce no additional audit entries beyond the first effective state change.
- SC-008: In validation scenarios, an ungracefully terminated host recovers its in-flight workflows through the existing timeout-based recovery path without regression in recovery latency or success rate relative to the current implementation.
- SC-009: A buggy ingress source that acknowledges pause but continues to deliver messages is detected within the first subsequent execution cycle it initiates, and its recorded state transitions to
PauseFailed. - SC-010: Dashboards filtering workflow instances by sub-status distinguish interrupted instances from suspended, cancelled, faulted, and crash-recovered instances without ambiguity.