Skip to content

Commit 4be2a51

Browse files
committed
feat(supervisor): elect pool coordinator and support planned handoffs
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1 parent 549cb46 commit 4be2a51

39 files changed

Lines changed: 9411 additions & 1991 deletions

‎.agents/skills/helm-dev-environment/SKILL.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,9 +289,12 @@ Use `mise run e2e:kubernetes:supervisor-topology` for the gateway supervisor
289289
topology prototype (`[openshell.gateway] supervisor_topology = "gateway"`). It
290290
uses `ci/values-supervisor-gateway-topology.yaml` (two gateway replicas, the
291291
external PostgreSQL fixture, Envoy) and runs CLI conformance plus
292-
`kubernetes_supervisor_topology`, which kills and pauses shared supervisor Pods
293-
to check fenced takeover of running agents. The partition step pauses one
294-
supervisor process through `crictl` inside the k3d or kind node container
292+
`kubernetes_supervisor_topology`. That test pauses the elected coordinator
293+
gateway (another gateway must take over while agents keep running), drains and
294+
rebalances the shared pool through the operator API over a `kubectl
295+
port-forward`, then kills and pauses shared supervisor Pods to check fenced
296+
takeover of running agents. The pause steps signal one gateway or supervisor
297+
process through `crictl` inside the k3d or kind node container
295298
(`OPENSHELL_E2E_KUBE_NODE_CONTAINER` overrides the derived name); run it only
296299
against a disposable cluster.
297300

‎crates/openshell-cli/tests/ensure_providers_integration.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,28 @@ impl OpenShell for TestOpenShell {
116116
Err(Status::unimplemented("not implemented in test"))
117117
}
118118

119+
async fn get_supervisor_pool_status(
120+
&self,
121+
_request: tonic::Request<openshell_core::proto::GetSupervisorPoolStatusRequest>,
122+
) -> Result<Response<openshell_core::proto::GetSupervisorPoolStatusResponse>, Status> {
123+
Err(Status::unimplemented("not implemented in test"))
124+
}
125+
126+
async fn start_supervisor_pool_operation(
127+
&self,
128+
_request: tonic::Request<openshell_core::proto::StartSupervisorPoolOperationRequest>,
129+
) -> Result<Response<openshell_core::proto::StartSupervisorPoolOperationResponse>, Status> {
130+
Err(Status::unimplemented("not implemented in test"))
131+
}
132+
133+
async fn cancel_supervisor_pool_operation(
134+
&self,
135+
_request: tonic::Request<openshell_core::proto::CancelSupervisorPoolOperationRequest>,
136+
) -> Result<Response<openshell_core::proto::CancelSupervisorPoolOperationResponse>, Status>
137+
{
138+
Err(Status::unimplemented("not implemented in test"))
139+
}
140+
119141
async fn peer_report_provider_readiness(
120142
&self,
121143
_request: tonic::Request<openshell_core::proto::ReportProviderReadinessRequest>,

‎crates/openshell-cli/tests/mtls_integration.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,28 @@ impl OpenShell for TestOpenShell {
6868
Err(Status::unimplemented("not implemented in test"))
6969
}
7070

71+
async fn get_supervisor_pool_status(
72+
&self,
73+
_request: tonic::Request<openshell_core::proto::GetSupervisorPoolStatusRequest>,
74+
) -> Result<Response<openshell_core::proto::GetSupervisorPoolStatusResponse>, Status> {
75+
Err(Status::unimplemented("not implemented in test"))
76+
}
77+
78+
async fn start_supervisor_pool_operation(
79+
&self,
80+
_request: tonic::Request<openshell_core::proto::StartSupervisorPoolOperationRequest>,
81+
) -> Result<Response<openshell_core::proto::StartSupervisorPoolOperationResponse>, Status> {
82+
Err(Status::unimplemented("not implemented in test"))
83+
}
84+
85+
async fn cancel_supervisor_pool_operation(
86+
&self,
87+
_request: tonic::Request<openshell_core::proto::CancelSupervisorPoolOperationRequest>,
88+
) -> Result<Response<openshell_core::proto::CancelSupervisorPoolOperationResponse>, Status>
89+
{
90+
Err(Status::unimplemented("not implemented in test"))
91+
}
92+
7193
async fn peer_report_provider_readiness(
7294
&self,
7395
_request: tonic::Request<openshell_core::proto::ReportProviderReadinessRequest>,

‎crates/openshell-cli/tests/provider_commands_integration.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,28 @@ impl OpenShell for TestOpenShell {
269269
Err(Status::unimplemented("not implemented in test"))
270270
}
271271

272+
async fn get_supervisor_pool_status(
273+
&self,
274+
_request: tonic::Request<openshell_core::proto::GetSupervisorPoolStatusRequest>,
275+
) -> Result<Response<openshell_core::proto::GetSupervisorPoolStatusResponse>, Status> {
276+
Err(Status::unimplemented("not implemented in test"))
277+
}
278+
279+
async fn start_supervisor_pool_operation(
280+
&self,
281+
_request: tonic::Request<openshell_core::proto::StartSupervisorPoolOperationRequest>,
282+
) -> Result<Response<openshell_core::proto::StartSupervisorPoolOperationResponse>, Status> {
283+
Err(Status::unimplemented("not implemented in test"))
284+
}
285+
286+
async fn cancel_supervisor_pool_operation(
287+
&self,
288+
_request: tonic::Request<openshell_core::proto::CancelSupervisorPoolOperationRequest>,
289+
) -> Result<Response<openshell_core::proto::CancelSupervisorPoolOperationResponse>, Status>
290+
{
291+
Err(Status::unimplemented("not implemented in test"))
292+
}
293+
272294
async fn peer_report_provider_readiness(
273295
&self,
274296
_request: tonic::Request<openshell_core::proto::ReportProviderReadinessRequest>,

‎crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,28 @@ impl OpenShell for TestOpenShell {
137137
Err(Status::unimplemented("not implemented in test"))
138138
}
139139

140+
async fn get_supervisor_pool_status(
141+
&self,
142+
_request: tonic::Request<openshell_core::proto::GetSupervisorPoolStatusRequest>,
143+
) -> Result<Response<openshell_core::proto::GetSupervisorPoolStatusResponse>, Status> {
144+
Err(Status::unimplemented("not implemented in test"))
145+
}
146+
147+
async fn start_supervisor_pool_operation(
148+
&self,
149+
_request: tonic::Request<openshell_core::proto::StartSupervisorPoolOperationRequest>,
150+
) -> Result<Response<openshell_core::proto::StartSupervisorPoolOperationResponse>, Status> {
151+
Err(Status::unimplemented("not implemented in test"))
152+
}
153+
154+
async fn cancel_supervisor_pool_operation(
155+
&self,
156+
_request: tonic::Request<openshell_core::proto::CancelSupervisorPoolOperationRequest>,
157+
) -> Result<Response<openshell_core::proto::CancelSupervisorPoolOperationResponse>, Status>
158+
{
159+
Err(Status::unimplemented("not implemented in test"))
160+
}
161+
140162
async fn peer_report_provider_readiness(
141163
&self,
142164
_request: tonic::Request<openshell_core::proto::ReportProviderReadinessRequest>,

‎crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,30 @@ macro_rules! mock_gateway {
206206
Err(tonic::Status::unimplemented("not implemented in test"))
207207
}
208208

209+
async fn get_supervisor_pool_status(
210+
&self,
211+
_request: tonic::Request<openshell_core::proto::GetSupervisorPoolStatusRequest>,
212+
) -> Result<tonic::Response<openshell_core::proto::GetSupervisorPoolStatusResponse>, tonic::Status>
213+
{
214+
Err(tonic::Status::unimplemented("not implemented in test"))
215+
}
216+
217+
async fn start_supervisor_pool_operation(
218+
&self,
219+
_request: tonic::Request<openshell_core::proto::StartSupervisorPoolOperationRequest>,
220+
) -> Result<tonic::Response<openshell_core::proto::StartSupervisorPoolOperationResponse>, tonic::Status>
221+
{
222+
Err(tonic::Status::unimplemented("not implemented in test"))
223+
}
224+
225+
async fn cancel_supervisor_pool_operation(
226+
&self,
227+
_request: tonic::Request<openshell_core::proto::CancelSupervisorPoolOperationRequest>,
228+
) -> Result<tonic::Response<openshell_core::proto::CancelSupervisorPoolOperationResponse>, tonic::Status>
229+
{
230+
Err(tonic::Status::unimplemented("not implemented in test"))
231+
}
232+
209233
$(async fn $method(&self, _: Request<$request>) -> Result<Response<$response>, Status> {
210234
Err(Status::unimplemented("unused test RPC"))
211235
})*

‎crates/openshell-cli/tests/sandbox_name_fallback_integration.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,28 @@ impl OpenShell for TestOpenShell {
8383
Err(Status::unimplemented("not implemented in test"))
8484
}
8585

86+
async fn get_supervisor_pool_status(
87+
&self,
88+
_request: tonic::Request<openshell_core::proto::GetSupervisorPoolStatusRequest>,
89+
) -> Result<Response<openshell_core::proto::GetSupervisorPoolStatusResponse>, Status> {
90+
Err(Status::unimplemented("not implemented in test"))
91+
}
92+
93+
async fn start_supervisor_pool_operation(
94+
&self,
95+
_request: tonic::Request<openshell_core::proto::StartSupervisorPoolOperationRequest>,
96+
) -> Result<Response<openshell_core::proto::StartSupervisorPoolOperationResponse>, Status> {
97+
Err(Status::unimplemented("not implemented in test"))
98+
}
99+
100+
async fn cancel_supervisor_pool_operation(
101+
&self,
102+
_request: tonic::Request<openshell_core::proto::CancelSupervisorPoolOperationRequest>,
103+
) -> Result<Response<openshell_core::proto::CancelSupervisorPoolOperationResponse>, Status>
104+
{
105+
Err(Status::unimplemented("not implemented in test"))
106+
}
107+
86108
async fn peer_report_provider_readiness(
87109
&self,
88110
_request: tonic::Request<openshell_core::proto::ReportProviderReadinessRequest>,

‎crates/openshell-sandbox-backend/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,8 @@ libc = "0.2"
4545
[target.'cfg(target_os = "linux")'.dependencies]
4646
rustix = { workspace = true, features = ["fs", "process"] }
4747

48+
[dev-dependencies]
49+
tokio = { workspace = true, features = ["test-util"] }
50+
4851
[lints]
4952
workspace = true

‎crates/openshell-sandbox-backend/src/runtime.rs‎

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,73 @@ struct RecoveredBoundary {
283283
}
284284

285285
/// Fence the previous owner through the runtime's recovery extension.
286+
/// How long a candidate keeps retrying `RecoveryHello` before giving up.
287+
/// The hello is retried BEFORE any grant is requested, so a transient
288+
/// failure (e.g. racing a relinquishing owner's connection teardown) never
289+
/// consumes the reserved epoch's single grant.
290+
const RECOVERY_HELLO_RETRY_BUDGET: Duration = Duration::from_secs(4);
291+
const RECOVERY_HELLO_RETRY_INTERVAL: Duration = Duration::from_millis(250);
292+
const RECOVERY_HELLO_ATTEMPT_TIMEOUT: Duration = Duration::from_millis(1_500);
293+
294+
async fn recovery_hello(client: &BoundaryClient) -> Result<Response, BackendError> {
295+
retry_within_budget(
296+
RECOVERY_HELLO_RETRY_BUDGET,
297+
RECOVERY_HELLO_ATTEMPT_TIMEOUT,
298+
RECOVERY_HELLO_RETRY_INTERVAL,
299+
|| client.call_once(Request::RecoveryHello),
300+
)
301+
.await
302+
}
303+
304+
/// Retry `attempt` on transient failures until `budget` is spent. Every
305+
/// attempt is bounded by the smaller of `attempt_timeout` and the time left,
306+
/// and no attempt or wait starts past the deadline, so the whole call never
307+
/// exceeds `budget`. A response or a definitive refusal (denied, terminated,
308+
/// unsupported) is returned immediately.
309+
async fn retry_within_budget<F, Fut>(
310+
budget: Duration,
311+
attempt_timeout: Duration,
312+
retry_interval: Duration,
313+
attempt: F,
314+
) -> Result<Response, BackendError>
315+
where
316+
F: Fn() -> Fut,
317+
Fut: Future<Output = Result<Response, BackendError>>,
318+
{
319+
let deadline = tokio::time::Instant::now() + budget;
320+
loop {
321+
let attempt_deadline = (tokio::time::Instant::now() + attempt_timeout).min(deadline);
322+
let result = tokio::time::timeout_at(attempt_deadline, attempt())
323+
.await
324+
.unwrap_or_else(|_| {
325+
Err(BackendError::Unavailable(
326+
"recovery hello timed out".to_string(),
327+
))
328+
});
329+
match result {
330+
// A response, or a definitive refusal, is final.
331+
Ok(response) => return Ok(response),
332+
Err(
333+
error @ (BackendError::Denied(_)
334+
| BackendError::Terminated(_)
335+
| BackendError::Unsupported(_)),
336+
) => return Err(error),
337+
Err(error) => {
338+
if tokio::time::Instant::now() + retry_interval >= deadline {
339+
return Err(error);
340+
}
341+
tracing::debug!(%error, "Recovery hello failed; retrying before requesting a grant");
342+
tokio::time::sleep(retry_interval).await;
343+
}
344+
}
345+
}
346+
}
347+
286348
async fn recover_boundary(
287349
client: &BoundaryClient,
288350
ownership: &SupervisorOwnership,
289351
) -> Result<RecoveredBoundary, BackendError> {
290-
let response = client.call_once(Request::RecoveryHello).await?;
352+
let response = recovery_hello(client).await?;
291353
let Response::RecoveryChallenge {
292354
protocol,
293355
challenge,
@@ -4380,3 +4442,61 @@ mod tests {
43804442
let _ = server.await;
43814443
}
43824444
}
4445+
4446+
#[cfg(test)]
4447+
mod recovery_hello_tests {
4448+
use super::{BackendError, Response, retry_within_budget};
4449+
use std::sync::atomic::{AtomicUsize, Ordering};
4450+
use std::time::Duration;
4451+
4452+
const BUDGET: Duration = Duration::from_secs(4);
4453+
const ATTEMPT: Duration = Duration::from_millis(1_500);
4454+
const INTERVAL: Duration = Duration::from_millis(250);
4455+
4456+
#[tokio::test(start_paused = true)]
4457+
async fn a_hanging_runtime_never_exceeds_the_hello_budget() {
4458+
let attempts = AtomicUsize::new(0);
4459+
let started = tokio::time::Instant::now();
4460+
let result = retry_within_budget(BUDGET, ATTEMPT, INTERVAL, || {
4461+
attempts.fetch_add(1, Ordering::SeqCst);
4462+
std::future::pending::<Result<Response, BackendError>>()
4463+
})
4464+
.await;
4465+
assert!(matches!(result, Err(BackendError::Unavailable(_))));
4466+
assert!(
4467+
started.elapsed() <= BUDGET,
4468+
"hello took {:?}, budget {BUDGET:?}",
4469+
started.elapsed()
4470+
);
4471+
// 1.5 s + 0.25 s + 1.5 s + 0.25 s + remaining 0.5 s: the last attempt
4472+
// is cut to the time left instead of a full 1.5 s.
4473+
assert_eq!(attempts.load(Ordering::SeqCst), 3);
4474+
}
4475+
4476+
#[tokio::test(start_paused = true)]
4477+
async fn transient_failures_are_retried_and_definitive_ones_are_not() {
4478+
let attempts = AtomicUsize::new(0);
4479+
let result = retry_within_budget(BUDGET, ATTEMPT, INTERVAL, || {
4480+
let n = attempts.fetch_add(1, Ordering::SeqCst);
4481+
async move {
4482+
if n < 2 {
4483+
Err(BackendError::Unavailable("connection refused".to_string()))
4484+
} else {
4485+
Ok(Response::MediationReady)
4486+
}
4487+
}
4488+
})
4489+
.await;
4490+
assert!(matches!(result, Ok(Response::MediationReady)));
4491+
assert_eq!(attempts.load(Ordering::SeqCst), 3);
4492+
4493+
let denied_attempts = AtomicUsize::new(0);
4494+
let denied = retry_within_budget(BUDGET, ATTEMPT, INTERVAL, || {
4495+
denied_attempts.fetch_add(1, Ordering::SeqCst);
4496+
async { Err(BackendError::Terminated("terminating".to_string())) }
4497+
})
4498+
.await;
4499+
assert!(matches!(denied, Err(BackendError::Terminated(_))));
4500+
assert_eq!(denied_attempts.load(Ordering::SeqCst), 1);
4501+
}
4502+
}

0 commit comments

Comments
 (0)