Skip to content

feat(runtime): worker validation gates - #7

Closed
sejori wants to merge 1 commit into
validation-gates-v2/1-health-splitfrom
validation-gates-v2/2-exec-gates
Closed

sejori wants to merge 1 commit into
validation-gates-v2/1-health-splitfrom
validation-gates-v2/2-exec-gates

Conversation

@sejori

@sejori sejori commented Oct 12, 2026 •

Copy link
Copy Markdown

Part 2 of 6 of the worker validation gates stack (DEP ai-dynamo/dynamo#15897). Based on part 1. Together with parts 3–6 it replaces #2–#5, cut down from ~4,950 to ~2,000 lines.

Summary

A worker can run checks against itself before it reports ready, so a broken worker stalls a rollout instead of serving.

DYN_WORKER_VALIDATION_GATES='{"smoke": {"exec": ["python3", "/checks/smoke.py"], "timeout_secs": 900}}'
  • Each gate runs once, in parallel, when the worker's first local engine registers (part 5 moves this to the loopback endpoint). Exit 0 is a pass; otherwise the end of stderr is the reason. A gate past its deadline fails and its process is killed.
  • /health reports 503 with each gate's state and reason until every gate passes. /live is unchanged, so a held worker isn't restarted.
  • Verdicts live in memory: a restarted container is checked again, and a failed gate holds the worker until the pod is deleted.
  • Startup fails on bad config, without the system status server, or in sidecar mode.

Unset, nothing changes.

Validation

Config parsing and error table, a failing gate holding the worker, the gate deadline, /health held while /live stays 200 for passing and failing gates, and the status-server requirement.

🤖 Generated with Claude Code

View guided diff Turn on auto-fix

A worker configured with DYN_WORKER_VALIDATION_GATES runs each gate
once, in parallel, as soon as its first local engine is registered, and
reports not ready on /health until every gate passes. /live is
unaffected, so a held worker is not restarted and a failing gate stalls
a rollout instead of serving.

A gate is a command run in the worker container with a deadline; exit
status 0 is a pass, and the tail of its stderr is the reason shown on
/health. Verdicts live in this process's memory, so a restarted
container is checked again. Misconfiguration fails startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Seb Ringrose <seb@doubleword.ai>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 8 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/runtime/src/validation/runner.rs">

<violation number="1" location="lib/runtime/src/validation/runner.rs:25">
P2: A timed-out check's stderr is discarded, so `/health` reports only `no verdict within ...` even when the process wrote a useful failure reason. Preserve the captured tail when returning the timeout verdict.</violation>

<violation number="2" location="lib/runtime/src/validation/runner.rs:39">
P2: A gate can leave its subprocesses running after cancellation or timeout: `kill_on_drop` terminates only the direct child. Start each check in its own process group and kill the group when the future is dropped.

(Based on your team's feedback about process-group cleanup on cancellation.)</violation>

<violation number="3" location="lib/runtime/src/validation/runner.rs:45">
P1: A verbose check can exhaust worker memory: `read_to_end` buffers all stderr even though the code retains only the last 2 KiB. Drain stderr into a bounded rolling tail buffer.</violation>
</file>

<file name="lib/runtime/src/validation/config.rs">

<violation number="1" location="lib/runtime/src/validation/config.rs:51">
P1: `std::env::var` returns `NotUnicode` for a non-UTF-8 value, and `.ok()` turns that into `None`, disabling validation and starting the worker unchecked. Treat only `NotPresent` as no configuration; return an error for `NotUnicode`.</violation>
</file>

<file name="lib/runtime/src/distributed.rs">

<violation number="1" location="lib/runtime/src/distributed.rs:498">
P1: This check verifies only that the status server is enabled; a bind failure still reaches the Worker log-and-continue arm, so a gated worker starts without `/health`. Return the bind error for gated workers while preserving the existing non-gated behavior.

(Based on your team's feedback about gated status server bind failures.)</violation>
</file>

Heads up: you’re close to your included review allowance. Set a flex budget so reviews don’t pause.

View guided diff | Turn on auto-fix | Re-trigger cubic


let mut stderr = Vec::new();
let mut pipe = child.stderr.take().expect("stderr is piped");
let (status, _) = tokio::join!(child.wait(), pipe.read_to_end(&mut stderr));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A verbose check can exhaust worker memory: read_to_end buffers all stderr even though the code retains only the last 2 KiB. Drain stderr into a bounded rolling tail buffer.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At lib/runtime/src/validation/runner.rs, line 45:

<comment>A verbose check can exhaust worker memory: `read_to_end` buffers all stderr even though the code retains only the last 2 KiB. Drain stderr into a bounded rolling tail buffer.</comment>

<file context>
@@ -0,0 +1,74 @@
+
+    let mut stderr = Vec::new();
+    let mut pipe = child.stderr.take().expect("stderr is piped");
+    let (status, _) = tokio::join!(child.wait(), pipe.read_to_end(&mut stderr));
+    let status = status.map_err(|error| format!("waiting for {:?}: {error}", argv[0]))?;
+    if status.success() {
</file context>

/// Read the configuration from the environment. `Ok(None)` when no gates
/// are configured, which keeps today's behaviour unchanged.
pub fn from_env() -> Result<Option<Self>> {
Self::parse(std::env::var(env_validation::DYN_WORKER_VALIDATION_GATES).ok())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: std::env::var returns NotUnicode for a non-UTF-8 value, and .ok() turns that into None, disabling validation and starting the worker unchecked. Treat only NotPresent as no configuration; return an error for NotUnicode.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At lib/runtime/src/validation/config.rs, line 51:

<comment>`std::env::var` returns `NotUnicode` for a non-UTF-8 value, and `.ok()` turns that into `None`, disabling validation and starting the worker unchecked. Treat only `NotPresent` as no configuration; return an error for `NotUnicode`.</comment>

<file context>
@@ -0,0 +1,144 @@
+    /// Read the configuration from the environment. `Ok(None)` when no gates
+    /// are configured, which keeps today's behaviour unchanged.
+    pub fn from_env() -> Result<Option<Self>> {
+        Self::parse(std::env::var(env_validation::DYN_WORKER_VALIDATION_GATES).ok())
+    }
+
</file context>
Suggested change
Self::parse(std::env::var(env_validation::DYN_WORKER_VALIDATION_GATES).ok())
let gates = match std::env::var(env_validation::DYN_WORKER_VALIDATION_GATES) {
Ok(gates) => Some(gates),
Err(std::env::VarError::NotPresent) => None,
Err(error) => return Err(error.into()),
};
Self::parse(gates)

return Ok(None);
};
anyhow::ensure!(
config.system_server_enabled(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This check verifies only that the status server is enabled; a bind failure still reaches the Worker log-and-continue arm, so a gated worker starts without /health. Return the bind error for gated workers while preserving the existing non-gated behavior.

(Based on your team's feedback about gated status server bind failures.)

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At lib/runtime/src/distributed.rs, line 498:

<comment>This check verifies only that the status server is enabled; a bind failure still reaches the Worker log-and-continue arm, so a gated worker starts without `/health`. Return the bind error for gated workers while preserving the existing non-gated behavior.

(Based on your team's feedback about gated status server bind failures.) </comment>

<file context>
@@ -477,6 +486,43 @@ impl DistributedRuntime {
+            return Ok(None);
+        };
+        anyhow::ensure!(
+            config.system_server_enabled(),
+            "worker validation gates hold /health, so they need the system status server; \
+             set DYN_SYSTEM_PORT"
</file context>

.await
.unwrap_or_else(|_| {
Err(format!(
"no verdict within timeout_secs={}",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A timed-out check's stderr is discarded, so /health reports only no verdict within ... even when the process wrote a useful failure reason. Preserve the captured tail when returning the timeout verdict.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At lib/runtime/src/validation/runner.rs, line 25:

<comment>A timed-out check's stderr is discarded, so `/health` reports only `no verdict within ...` even when the process wrote a useful failure reason. Preserve the captured tail when returning the timeout verdict.</comment>

<file context>
@@ -0,0 +1,74 @@
+        .await
+        .unwrap_or_else(|_| {
+            Err(format!(
+                "no verdict within timeout_secs={}",
+                gate.timeout.as_secs()
+            ))
</file context>

.env("DYN_VALIDATION_GATE", gate)
.stdin(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A gate can leave its subprocesses running after cancellation or timeout: kill_on_drop terminates only the direct child. Start each check in its own process group and kill the group when the future is dropped.

(Based on your team's feedback about process-group cleanup on cancellation.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At lib/runtime/src/validation/runner.rs, line 39:

<comment>A gate can leave its subprocesses running after cancellation or timeout: `kill_on_drop` terminates only the direct child. Start each check in its own process group and kill the group when the future is dropped.

(Based on your team's feedback about process-group cleanup on cancellation.) </comment>

<file context>
@@ -0,0 +1,74 @@
+        .env("DYN_VALIDATION_GATE", gate)
+        .stdin(Stdio::null())
+        .stderr(Stdio::piped())
+        .kill_on_drop(true)
+        .spawn()
+        .map_err(|error| format!("could not run {:?}: {error}", argv[0]))?;
</file context>

@sejori

sejori commented Oct 15, 2026

Copy link
Copy Markdown
Author

Superseded by ai-dynamo#16524 (prep) and ai-dynamo#16525 (gates), which carry this series rebased on current upstream main, including the per-gate phase that replaces DYN_WORKER_VALIDATION_HOLD (see DEP ai-dynamo#15897). Closing so there's one copy to maintain.

@sejori sejori closed this Oct 15, 2026

This branch had an error being deployed

1 failed deployment
external_collaborator — 9561a349 Deployed Oct 12, 2026 by sejori via ok-to-test #14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant