diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 336956415e..ac9ba0ce70 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -119,15 +119,15 @@ use windmill_queue::{ }; use windmill_worker::{ result_processor::handle_job_error, JobCompletedSender, JobIsolationLevel, - OtelTracingProxySettings, SameWorkerSender, WorkspaceRegistryMap, BUNFIG_INSTALL_SCOPES, - BUN_INSTALL_MIN_RELEASE_AGE, CARGO_REGISTRIES, INSTANCE_PYTHON_VERSION, JAVA_HOME_DIR, - JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, MAVEN_REPOS, MAVEN_SETTINGS_XML, - NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE, NSJAIL_TMPFS_SIZE_MB, - NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, - PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, SANDBOX_IMAGE_CACHE_MAX_MB, - SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, SANDBOX_IMAGE_PULL_POLICY, - SANDBOX_REGISTRY_AUTH, UNSHARE_PATH, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY, - UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, + OtelTracingProxySettings, SameWorkerSender, StepFailureKind, WorkspaceRegistryMap, + BUNFIG_INSTALL_SCOPES, BUN_INSTALL_MIN_RELEASE_AGE, CARGO_REGISTRIES, INSTANCE_PYTHON_VERSION, + JAVA_HOME_DIR, JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, MAVEN_REPOS, + MAVEN_SETTINGS_XML, NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE, + NSJAIL_TMPFS_SIZE_MB, NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, + PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, + SANDBOX_IMAGE_CACHE_MAX_MB, SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, + SANDBOX_IMAGE_PULL_POLICY, SANDBOX_REGISTRY_AUTH, UNSHARE_PATH, UV_EXCLUDE_NEWER, + UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, }; #[cfg(feature = "parquet")] @@ -4700,7 +4700,12 @@ async fn handle_zombie_jobs(db: &Pool, base_internal_url: &str, node_n memory_peak, None, error::Error::ExecutionErr(error_message.clone()), - matches!(error_kind, ErrorMessage::SameWorker), // unrecoverable if the job is a same worker zombie + // a same worker zombie means the worker itself is gone + if matches!(error_kind, ErrorMessage::SameWorker) { + StepFailureKind::Unrecoverable + } else { + StepFailureKind::Normal + }, Some(&same_worker_tx_never_used), "", node_name, diff --git a/backend/tests/suspend_resume.rs b/backend/tests/suspend_resume.rs index 29fe38d2c3..d112ca0b96 100644 --- a/backend/tests/suspend_resume.rs +++ b/backend/tests/suspend_resume.rs @@ -235,6 +235,88 @@ mod suspend_resume { Ok(()) } + /// A suspend gate that ends without approval leaves the worker that ran the approval step + /// alive, so the error handler it routes to must stay pinned to that worker rather than + /// being unpinned and routed by tag — which would break the `./shared` contract of a + /// `same_worker` flow. + #[cfg(feature = "deno_core")] + #[sqlx::test(fixtures("base"))] + async fn disapproved_suspend_keeps_same_worker_pin(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let value: FlowValue = serde_json::from_value(json!({ + "same_worker": true, + "modules": [{ + "id": "a", + "value": { + "input_transforms": { + "port": { "type": "javascript", "expr": "flow_input.port" }, + }, + "type": "rawscript", + "language": "deno", + "content": "\ + export async function main(port) {\ + const job = Deno.env.get('WM_JOB_ID');\ + const token = Deno.env.get('WM_TOKEN');\ + const secret = await (await fetch(\ + `http://localhost:${port}/api/w/test-workspace/jobs/job_signature/${job}/0?token=${token}&approver=ruben`,\ + { headers: { 'Authorization': `Bearer ${token}` } }\ + )).text();\ + await fetch(\ + `http://localhost:${port}/api/w/test-workspace/jobs_u/cancel/${job}/0/${secret}?approver=ruben`,\ + { method: 'POST', body: JSON.stringify('from job'), headers: { 'content-type': 'application/json' } }\ + );\ + return 'a ran';\ + }", + }, + "suspend": { "required_events": 1 }, + }, { + "id": "b", + "value": { + "input_transforms": {}, + "type": "rawscript", + "language": "deno", + "content": "export function main() { return 'b ran' }", + }, + // The gate holds `b` back, so `b` never runs and its error policy describes + // nothing: honouring it here would skip `b` instead of reaching the handler. + "continue_on_error": true, + }], + "failure_module": { + "id": "failure", + "value": { + "input_transforms": {}, + "type": "rawscript", + "language": "deno", + "content": "export function main() { return 'handled' }", + }, + }, + }))?; + + let completed = + RunJob::from(JobPayload::RawFlow { value, path: None, restarted_from: None }) + .arg("port", json!(port)) + .run_until_complete(&db, false, port) + .await; + + server.close().await.unwrap(); + + assert_eq!(json!("handled"), completed.json_result().unwrap()); + + let same_worker: Option = sqlx::query_scalar( + "SELECT same_worker FROM v2_job WHERE parent_job = $1 AND flow_step_id = 'failure'", + ) + .bind(completed.id) + .fetch_one(&db) + .await?; + assert_eq!(Some(true), same_worker); + + Ok(()) + } + /// Test that self-approval is blocked when self_approval_disabled is true. /// /// This test verifies that when a flow has an approval step with self_approval_disabled=true, diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 2e11ff3e4c..67aac0503e 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -4055,7 +4055,7 @@ async fn test_failure_module(db: Pool) -> anyhow::Result<()> { /// Push `flow`, run it on a real worker until its first step is running, then simulate /// `monitor::handle_zombie_jobs` reaping that step unrecoverably (its worker crashed/OOM'd) -/// by calling `handle_job_error(..., unrecoverable = true, ...)` exactly as the monitor does. +/// by calling `handle_job_error(..., StepFailureKind::Unrecoverable, ...)` exactly as the monitor does. /// Returns the flow's completed result. #[cfg(feature = "deno_core")] async fn run_flow_until_step_running_then_fail_unrecoverably( @@ -4070,7 +4070,7 @@ async fn run_flow_until_step_running_then_fail_unrecoverably( use windmill_common::client::AuthedClient; use windmill_common::KillpillSender; use windmill_queue::{get_queued_job_v2, MiniCompletedJob, SameWorkerPayload}; - use windmill_worker::{JobCompletedSender, SameWorkerSender}; + use windmill_worker::{JobCompletedSender, SameWorkerSender, StepFailureKind}; let flow_id = RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) @@ -4135,7 +4135,7 @@ async fn run_flow_until_step_running_then_fail_unrecoverably( windmill_common::error::Error::ExecutionErr( "simulated worker OOM crash".to_string(), ), - true, // unrecoverable + StepFailureKind::Unrecoverable, Some(&sw_tx), "", "test-monitor", diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index cdfe9895d2..a14b8aa739 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -52,7 +52,7 @@ use crate::{ otel_oss::add_root_flow_job_to_otlp, worker_flow::update_flow_status_after_job_completion, JobCompletedReceiver, JobCompletedSender, SameWorkerSender, SendResult, SendResultPayload, - UpdateFlow, SAME_WORKER_REQUIREMENTS, + StepFailureKind, UpdateFlow, SAME_WORKER_REQUIREMENTS, }; use windmill_common::client::AuthedClient; @@ -465,7 +465,7 @@ pub fn start_background_processor( worker_dir, stop_early_override, token, - unrecoverable, + step_failure, }), time, }) => { @@ -486,7 +486,7 @@ pub fn start_background_processor( None, Arc::new(result), None, - unrecoverable, + step_failure, &same_worker_tx, &worker_dir, stop_early_override, @@ -792,7 +792,7 @@ pub async fn handle_receive_completed_job( mem_peak, canceled_by, err, - false, + StepFailureKind::Normal, same_worker_tx.clone(), &worker_dir, worker_name, @@ -1594,7 +1594,7 @@ pub async fn process_completed_job( canceled_by, result, started_at.map(|x| FlowJobDuration { started_at: x, duration_ms: duration }), - false, + StepFailureKind::Normal, &same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).to_owned(), &worker_dir, None, @@ -1701,7 +1701,7 @@ pub async fn process_completed_job( duration_ms: d, }) }), - false, + StepFailureKind::Normal, &same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).to_owned(), &worker_dir, None, @@ -1979,7 +1979,7 @@ pub async fn handle_job_error( mem_peak: i32, canceled_by: Option, err: Error, - unrecoverable: bool, + step_failure: StepFailureKind, same_worker_tx: Option<&SameWorkerSender>, worker_dir: &str, worker_name: &str, @@ -2029,7 +2029,7 @@ pub async fn handle_job_error( canceled_by.clone(), Arc::new(serde_json::value::to_raw_value(&wrapped_error).unwrap()), None, - unrecoverable, + step_failure, &same_worker_tx.expect(SAME_WORKER_REQUIREMENTS).clone(), worker_dir, None, diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index b03cfa7e48..4703c12556 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -1747,7 +1747,7 @@ pub async fn handle_all_job_kind_error( 0, None, err, - false, + StepFailureKind::Normal, same_worker_tx, &worker_dir, &worker_name, @@ -3639,10 +3639,38 @@ pub struct UpdateFlow { pub worker_dir: String, pub stop_early_override: Option, pub token: String, - /// Whether the flow must be resumed as if the previous step's worker had died: no retry, - /// straight to the failure module, and nothing pinned to this worker. Only true when the - /// flow is handed back from a state no live step can recover from. - pub unrecoverable: bool, + pub step_failure: StepFailureKind, +} + +/// Why the step a flow is being resumed from failed, which bounds what the engine may do next. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StepFailureKind { + /// The step failed by running, or did not fail at all. + Normal, + /// A suspend gate was disapproved or timed out. The worker that ran the approval step is + /// still alive, but the failure is recorded against the step the gate was holding back, + /// which never ran — so that step's `retry` and `continue_on_error` describe nothing that + /// happened, and honouring them would re-open the gate or skip the step outright. + /// `suspend.continue_on_disapprove_timeout` is how a flow opts into continuing past a gate. + SuspendNotApproved, + /// The step's worker died (OOM/zombie), or the flow status update itself errored. Neither + /// leaves state worth pinning to: in the first case that worker is gone, in the second the + /// flow's own bookkeeping is what just broke. + Unrecoverable, +} + +impl StepFailureKind { + /// Whether the failed module's own `retry` / `continue_on_error` still describe the + /// failure at hand. When they don't, the failure module is the only way forward. + pub fn honors_step_error_policy(self) -> bool { + matches!(self, Self::Normal) + } + + /// Whether follow-up work may still be pinned to the worker that ran the previous step, + /// via `same_worker` or dedicated flow-module runners. + pub fn keeps_worker_pin(self) -> bool { + !matches!(self, Self::Unrecoverable) + } } async fn do_nativets( @@ -3944,8 +3972,8 @@ pub async fn handle_queued_job( flow_runners, &killpill_rx, // A freshly pulled flow job is being executed by a live worker; the prior - // step (if any) completed normally, so this is never unrecoverable here. - false, + // step (if any) completed normally. + StepFailureKind::Normal, )) .warn_after_seconds(10) .await?; diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index df9e63511c..7066b35945 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -15,8 +15,8 @@ use crate::common::{cached_result_path, get_root_job_id, save_in_cache, transfor use crate::js_eval::{eval_timeout, IdContext}; use crate::worker_utils::get_tag_and_concurrency; use crate::{ - JobCompletedSender, PreviousResult, SameWorkerSender, SendResultPayload, UpdateFlow, - KEEP_JOB_DIR, + JobCompletedSender, PreviousResult, SameWorkerSender, SendResultPayload, StepFailureKind, + UpdateFlow, KEEP_JOB_DIR, }; use anyhow::Context; @@ -169,7 +169,7 @@ pub async fn update_flow_status_after_job_completion( canceled_by: Option, result: Arc>, flow_job_duration: Option, - unrecoverable: bool, + step_failure: StepFailureKind, same_worker_tx: &SameWorkerSender, worker_dir: &str, stop_early_override: Option, @@ -192,7 +192,7 @@ pub async fn update_flow_status_after_job_completion( stop_early_override, has_triggered_error_handler: false, }; - let mut unrecoverable = unrecoverable; + let mut step_failure = step_failure; loop { potentially_crash_for_testing(); let nrec = match Box::pin(update_flow_status_after_job_completion_internal( @@ -205,7 +205,7 @@ pub async fn update_flow_status_after_job_completion( rec.canceled_by, rec.flow_job_duration.clone(), rec.result, - unrecoverable, + step_failure, same_worker_tx, worker_dir, rec.stop_early_override, @@ -234,7 +234,7 @@ pub async fn update_flow_status_after_job_completion( Arc::new(to_raw_value(&Json(&WrappedError { error: json!(e.to_string()), }))), - true, + StepFailureKind::Unrecoverable, same_worker_tx, worker_dir, rec.stop_early_override, @@ -249,7 +249,7 @@ pub async fn update_flow_status_after_job_completion( .await? } }; - unrecoverable = false; + step_failure = StepFailureKind::Normal; match nrec { UpdateFlowStatusAfterJobCompletion::Done(job) => { @@ -421,7 +421,7 @@ pub async fn update_flow_status_after_job_completion_internal( canceled_by: Option, mut flow_job_duration: Option, result: Arc>, - unrecoverable: bool, + step_failure: StepFailureKind, same_worker_tx: &SameWorkerSender, worker_dir: &str, stop_early_override: Option, @@ -1298,11 +1298,11 @@ pub async fn update_flow_status_after_job_completion_internal( }), ) } else { - // An unrecoverable failure (worker crash/OOM) must reach the error handler - // even on a continue_on_error step, so don't advance the step counter past - // the failed module — otherwise the flow would silently continue to the next - // step and hide the worker death. - let inc = if !unrecoverable && continue_on_error { + // A failure the module's error policy does not describe must reach the error + // handler even on a continue_on_error step, so don't advance the step + // counter past the failed module — otherwise the flow would silently + // continue to the next step and hide it. + let inc = if step_failure.honors_step_error_policy() && continue_on_error { let retry = current_module .as_ref() .and_then(|x| x.retry.clone()) @@ -1741,19 +1741,20 @@ pub async fn update_flow_status_after_job_completion_internal( // enclosing job/subflow. Detect that case and treat the flow as successful. let recoverable_failure_at_last_step = !success && is_last_step - && !unrecoverable + && step_failure.honors_step_error_policy() && (skip_seq_branch_failure || skip_loop_failures || continue_on_error); let should_continue_flow = match success { _ if stop_early => stop_early_err_msg.is_some() && flow_value.failure_module.is_some(), // if stop_early_err_msg some, we want to trigger the error handler before stopping the flow, if any _ if flow_job.is_canceled() => false, true => !is_last_step, - // An unrecoverable failure (a step killed by a worker crash/OOM and surfaced by - // the zombie handler, or an error raised while updating the flow status itself) - // must not be retried or silently skipped, but it should still trigger the flow's - // error handler: an OOM/worker death is precisely when the error handler is expected - // to run. Continue the flow only to reach the failure module, never to retry. - false if unrecoverable => { + // A failure the module's error policy does not describe (a worker crash/OOM + // surfaced by the zombie handler, an error raised while updating the flow status, + // a suspend gate that ended without approval) must not be retried or silently + // skipped, but it should still trigger the flow's error handler — that is + // precisely when the error handler is expected to run. Continue the flow only to + // reach the failure module, never to retry. + false if !step_failure.honors_step_error_policy() => { !is_failure_step && !has_triggered_error_handler && flow_value.failure_module.is_some() @@ -1776,7 +1777,7 @@ pub async fn update_flow_status_after_job_completion_internal( success = true; } - tracing::info!(id = %flow_job.id, root_id = %job_root, success = %success, stop_early = %stop_early, is_last_step = %is_last_step, unrecoverable = %unrecoverable, + tracing::info!(id = %flow_job.id, root_id = %job_root, success = %success, stop_early = %stop_early, is_last_step = %is_last_step, step_failure = ?step_failure, skip_seq_branch_failure = %skip_seq_branch_failure, skip_loop_failures = %skip_loop_failures, current_module_id = %current_module.map(|x| x.id.clone()).unwrap_or_default(), continue_on_error = %continue_on_error, should_continue_flow = %should_continue_flow, "computed if flow should continue"); @@ -2108,7 +2109,7 @@ pub async fn update_flow_status_after_job_completion_internal( worker_name, flow_runners, &killpill_rx, - unrecoverable, + step_failure, )) .warn_after_seconds(10) .await @@ -2799,10 +2800,9 @@ pub async fn handle_flow( worker_name: &str, flow_runners: Option>, killpill_rx: &tokio::sync::broadcast::Receiver<()>, - // The previous step failed unrecoverably (e.g. a worker crash/OOM surfaced by the - // zombie handler). The next pushed step can only be the error handler (failure - // module), and it must not be pinned to the dead worker via same_worker. - unrecoverable: bool, + // How the step this flow is resuming from failed, which bounds what may be pushed next: + // see [`StepFailureKind`]. + step_failure: StepFailureKind, ) -> anyhow::Result<()> { let flow = flow_data.value(); @@ -2984,7 +2984,7 @@ pub async fn handle_flow( flow_runners.clone(), job_completed_tx.clone(), &killpill_rx, - unrecoverable, + step_failure, )) .warn_after_seconds(10) .await?; @@ -3167,10 +3167,9 @@ async fn push_next_flow_job( flow_runners: Option>, job_completed_tx: JobCompletedSender, killpill_rx: &tokio::sync::broadcast::Receiver<()>, - // The prior step failed unrecoverably (worker crash/OOM). The only step pushed - // from here is the error handler, which must run on a live worker rather than - // being pinned to the dead one via same_worker / dedicated runners. - unrecoverable: bool, + // How the prior step failed, which bounds what may be pushed next: see + // [`StepFailureKind`]. + step_failure: StepFailureKind, ) -> error::Result { let job_root = flow_job .flow_innermost_root_job @@ -3231,7 +3230,7 @@ async fn push_next_flow_job( w_id: flow_job.workspace_id.clone(), worker_dir: worker_dir.to_string(), token: client.token.clone(), - unrecoverable, + step_failure, }))); } @@ -3286,7 +3285,7 @@ async fn push_next_flow_job( w_id: flow_job.workspace_id.clone(), worker_dir: worker_dir.to_string(), token: client.token.clone(), - unrecoverable, + step_failure, } ))); } @@ -3331,7 +3330,7 @@ async fn push_next_flow_job( w_id: flow_job.workspace_id.clone(), worker_dir: worker_dir.to_string(), token: client.token.clone(), - unrecoverable, + step_failure, }))); } } @@ -3664,10 +3663,7 @@ async fn push_next_flow_job( w_id: flow_job.workspace_id.clone(), worker_dir: worker_dir.to_string(), token: client.token.clone(), - // A suspend that was disapproved or ran out its timeout cannot be - // resumed by anything the flow does next, so the failure module is the - // only way forward. - unrecoverable: true, + step_failure: StepFailureKind::SuspendNotApproved, }))); } } @@ -3737,10 +3733,11 @@ async fn push_next_flow_job( } }; - // An unrecoverable failure (worker crash/OOM) must not be retried — the original worker - // and its state are gone — so skip retry evaluation and fall straight through to the - // failure module below. - let retry = if !unrecoverable && matches!(&status_module, FlowStatusModule::Failure { .. },) { + // Retry is a policy on the step's own execution: skip it for a failure the step did not + // produce by running, and fall straight through to the failure module below. + let retry = if step_failure.honors_step_error_policy() + && matches!(&status_module, FlowStatusModule::Failure { .. },) + { let retry = &module.retry.clone().unwrap_or_default(); evaluate_retry( retry, @@ -3755,13 +3752,13 @@ async fn push_next_flow_job( None }; let get_args_from_id = match &status_module { - // `|| unrecoverable`: a worker crash/OOM routes to the failure module even on a - // continue_on_error step (whose failures are normally tolerated), matching the - // `unrecoverable` decision in update_flow_status_after_job_completion_internal. + // `|| !honors_step_error_policy()`: such a failure routes to the failure module even + // on a continue_on_error step (whose failures are normally tolerated), matching the + // decision in update_flow_status_after_job_completion_internal. FlowStatusModule::Failure { job, .. } if retry.as_ref().is_some() || !module.continue_on_error.is_some_and(|x| x) - || unrecoverable => + || !step_failure.honors_step_error_policy() => { if let Some((fail_count, retry_in)) = retry { tracing::debug!( @@ -4082,7 +4079,7 @@ async fn push_next_flow_job( None, result, None, - false, + StepFailureKind::Normal, same_worker_tx, worker_dir, None, @@ -4110,7 +4107,7 @@ async fn push_next_flow_job( .as_ref() .is_some_and(|fr| fr.job_id == flow_job.id); - let continue_with_runners = !unrecoverable + let continue_with_runners = step_failure.keeps_worker_pin() && (start_runners || (flow_runners.is_some() && !do_not_pass_runners)) && module.suspend.is_none() && module.sleep.is_none(); @@ -4120,10 +4117,10 @@ async fn push_next_flow_job( let job_same_worker = flow_job.same_worker && matches!(flow_job.kind, JobKind::Flow) && flow_job.runnable_id.is_some(); - // After an unrecoverable failure the original worker is gone, so the error handler - // step is pushed as a regular queued job (any live worker can pick it up) instead of - // being signaled to the dead worker via same_worker — which would strand it forever. - let continue_on_same_worker = !unrecoverable + // Without a worker worth pinning to, the error handler step is pushed as a regular queued + // job (any live worker can pick it up) instead of being signaled via same_worker to a + // worker that may be dead — which would strand it forever. + let continue_on_same_worker = step_failure.keeps_worker_pin() && (flow.same_worker || job_same_worker) && module.suspend.is_none() && module.sleep.is_none(); diff --git a/frontend/src/lib/components/flows/content/FlowRunSettings.svelte b/frontend/src/lib/components/flows/content/FlowRunSettings.svelte index 9c0c6d7de5..a1dae0f3a4 100644 --- a/frontend/src/lib/components/flows/content/FlowRunSettings.svelte +++ b/frontend/src/lib/components/flows/content/FlowRunSettings.svelte @@ -107,6 +107,13 @@ const s3Snippet = $derived(s3Language ? s3Scripts[s3Language][s3Kind] : undefined) const concurrencyOn = $derived(hasInlineConcurrency(flowModule)) const concurrencyOff = $derived(!$enterpriseLicense || !concurrencyOn) + // A resolved approval is recorded against the step the gate holds back, not this one, so + // `continue_on_error` never sees it — the suspend option is the only way to continue past it. + const suspendNeedsItsOwnContinueToggle = $derived( + Boolean(flowModule.continue_on_error) && + Boolean(flowModule.suspend) && + !flowModule.suspend?.continue_on_disapprove_timeout + ) {#snippet sectionHeader(title: string)} @@ -153,7 +160,7 @@ -
+
+ {#if suspendNeedsItsOwnContinueToggle} + + This only applies when the step's own code fails. A disapproval or an approval timeout + is not a failure of this step, so it still stops the flow. To continue past those, + turn on "Continue on disapproval/timeout" in the approval settings. + + {/if}