Skip to content

Commit 68a8b18

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

38 files changed

Lines changed: 9370 additions & 1949 deletions

File tree

‎.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
@@ -115,6 +115,28 @@ impl OpenShell for TestOpenShell {
115115
Err(Status::unimplemented("not implemented in test"))
116116
}
117117

118+
async fn get_supervisor_pool_status(
119+
&self,
120+
_request: tonic::Request<openshell_core::proto::GetSupervisorPoolStatusRequest>,
121+
) -> Result<Response<openshell_core::proto::GetSupervisorPoolStatusResponse>, Status> {
122+
Err(Status::unimplemented("not implemented in test"))
123+
}
124+
125+
async fn start_supervisor_pool_operation(
126+
&self,
127+
_request: tonic::Request<openshell_core::proto::StartSupervisorPoolOperationRequest>,
128+
) -> Result<Response<openshell_core::proto::StartSupervisorPoolOperationResponse>, Status> {
129+
Err(Status::unimplemented("not implemented in test"))
130+
}
131+
132+
async fn cancel_supervisor_pool_operation(
133+
&self,
134+
_request: tonic::Request<openshell_core::proto::CancelSupervisorPoolOperationRequest>,
135+
) -> Result<Response<openshell_core::proto::CancelSupervisorPoolOperationResponse>, Status>
136+
{
137+
Err(Status::unimplemented("not implemented in test"))
138+
}
139+
118140
async fn peer_report_provider_readiness(
119141
&self,
120142
_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
@@ -268,6 +268,28 @@ impl OpenShell for TestOpenShell {
268268
Err(Status::unimplemented("not implemented in test"))
269269
}
270270

271+
async fn get_supervisor_pool_status(
272+
&self,
273+
_request: tonic::Request<openshell_core::proto::GetSupervisorPoolStatusRequest>,
274+
) -> Result<Response<openshell_core::proto::GetSupervisorPoolStatusResponse>, Status> {
275+
Err(Status::unimplemented("not implemented in test"))
276+
}
277+
278+
async fn start_supervisor_pool_operation(
279+
&self,
280+
_request: tonic::Request<openshell_core::proto::StartSupervisorPoolOperationRequest>,
281+
) -> Result<Response<openshell_core::proto::StartSupervisorPoolOperationResponse>, Status> {
282+
Err(Status::unimplemented("not implemented in test"))
283+
}
284+
285+
async fn cancel_supervisor_pool_operation(
286+
&self,
287+
_request: tonic::Request<openshell_core::proto::CancelSupervisorPoolOperationRequest>,
288+
) -> Result<Response<openshell_core::proto::CancelSupervisorPoolOperationResponse>, Status>
289+
{
290+
Err(Status::unimplemented("not implemented in test"))
291+
}
292+
271293
async fn peer_report_provider_readiness(
272294
&self,
273295
_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
@@ -39,6 +39,9 @@ libc = "0.2"
3939
[target.'cfg(target_os = "linux")'.dependencies]
4040
rustix = { workspace = true, features = ["fs", "process"] }
4141

42+
[dev-dependencies]
43+
tokio = { workspace = true, features = ["test-util"] }
44+
4245
[build-dependencies]
4346
protoc-bin-vendored = { workspace = true }
4447
tonic-prost-build = { workspace = true }

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

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,73 @@ struct RecoveredBoundary {
130130
}
131131

132132
/// Fence the previous owner through the runtime's recovery extension.
133+
/// How long a candidate keeps retrying `RecoveryHello` before giving up.
134+
/// The hello is retried BEFORE any grant is requested, so a transient
135+
/// failure (e.g. racing a relinquishing owner's connection teardown) never
136+
/// consumes the reserved epoch's single grant.
137+
const RECOVERY_HELLO_RETRY_BUDGET: Duration = Duration::from_secs(4);
138+
const RECOVERY_HELLO_RETRY_INTERVAL: Duration = Duration::from_millis(250);
139+
const RECOVERY_HELLO_ATTEMPT_TIMEOUT: Duration = Duration::from_millis(1_500);
140+
141+
async fn recovery_hello(client: &BoundaryClient) -> Result<Response, BackendError> {
142+
retry_within_budget(
143+
RECOVERY_HELLO_RETRY_BUDGET,
144+
RECOVERY_HELLO_ATTEMPT_TIMEOUT,
145+
RECOVERY_HELLO_RETRY_INTERVAL,
146+
|| client.call_once(Request::RecoveryHello),
147+
)
148+
.await
149+
}
150+
151+
/// Retry `attempt` on transient failures until `budget` is spent. Every
152+
/// attempt is bounded by the smaller of `attempt_timeout` and the time left,
153+
/// and no attempt or wait starts past the deadline, so the whole call never
154+
/// exceeds `budget`. A response or a definitive refusal (denied, terminated,
155+
/// unsupported) is returned immediately.
156+
async fn retry_within_budget<F, Fut>(
157+
budget: Duration,
158+
attempt_timeout: Duration,
159+
retry_interval: Duration,
160+
attempt: F,
161+
) -> Result<Response, BackendError>
162+
where
163+
F: Fn() -> Fut,
164+
Fut: Future<Output = Result<Response, BackendError>>,
165+
{
166+
let deadline = tokio::time::Instant::now() + budget;
167+
loop {
168+
let attempt_deadline = (tokio::time::Instant::now() + attempt_timeout).min(deadline);
169+
let result = tokio::time::timeout_at(attempt_deadline, attempt())
170+
.await
171+
.unwrap_or_else(|_| {
172+
Err(BackendError::Unavailable(
173+
"recovery hello timed out".to_string(),
174+
))
175+
});
176+
match result {
177+
// A response, or a definitive refusal, is final.
178+
Ok(response) => return Ok(response),
179+
Err(
180+
error @ (BackendError::Denied(_)
181+
| BackendError::Terminated(_)
182+
| BackendError::Unsupported(_)),
183+
) => return Err(error),
184+
Err(error) => {
185+
if tokio::time::Instant::now() + retry_interval >= deadline {
186+
return Err(error);
187+
}
188+
tracing::debug!(%error, "Recovery hello failed; retrying before requesting a grant");
189+
tokio::time::sleep(retry_interval).await;
190+
}
191+
}
192+
}
193+
}
194+
133195
async fn recover_boundary(
134196
client: &BoundaryClient,
135197
ownership: &SupervisorOwnership,
136198
) -> Result<RecoveredBoundary, BackendError> {
137-
let response = client.call_once(Request::RecoveryHello).await?;
199+
let response = recovery_hello(client).await?;
138200
let Response::RecoveryChallenge {
139201
protocol,
140202
challenge,
@@ -3920,3 +3982,61 @@ mod tests {
39203982
let _ = server.await;
39213983
}
39223984
}
3985+
3986+
#[cfg(test)]
3987+
mod recovery_hello_tests {
3988+
use super::{BackendError, Response, retry_within_budget};
3989+
use std::sync::atomic::{AtomicUsize, Ordering};
3990+
use std::time::Duration;
3991+
3992+
const BUDGET: Duration = Duration::from_secs(4);
3993+
const ATTEMPT: Duration = Duration::from_millis(1_500);
3994+
const INTERVAL: Duration = Duration::from_millis(250);
3995+
3996+
#[tokio::test(start_paused = true)]
3997+
async fn a_hanging_runtime_never_exceeds_the_hello_budget() {
3998+
let attempts = AtomicUsize::new(0);
3999+
let started = tokio::time::Instant::now();
4000+
let result = retry_within_budget(BUDGET, ATTEMPT, INTERVAL, || {
4001+
attempts.fetch_add(1, Ordering::SeqCst);
4002+
std::future::pending::<Result<Response, BackendError>>()
4003+
})
4004+
.await;
4005+
assert!(matches!(result, Err(BackendError::Unavailable(_))));
4006+
assert!(
4007+
started.elapsed() <= BUDGET,
4008+
"hello took {:?}, budget {BUDGET:?}",
4009+
started.elapsed()
4010+
);
4011+
// 1.5 s + 0.25 s + 1.5 s + 0.25 s + remaining 0.5 s: the last attempt
4012+
// is cut to the time left instead of a full 1.5 s.
4013+
assert_eq!(attempts.load(Ordering::SeqCst), 3);
4014+
}
4015+
4016+
#[tokio::test(start_paused = true)]
4017+
async fn transient_failures_are_retried_and_definitive_ones_are_not() {
4018+
let attempts = AtomicUsize::new(0);
4019+
let result = retry_within_budget(BUDGET, ATTEMPT, INTERVAL, || {
4020+
let n = attempts.fetch_add(1, Ordering::SeqCst);
4021+
async move {
4022+
if n < 2 {
4023+
Err(BackendError::Unavailable("connection refused".to_string()))
4024+
} else {
4025+
Ok(Response::MediationReady)
4026+
}
4027+
}
4028+
})
4029+
.await;
4030+
assert!(matches!(result, Ok(Response::MediationReady)));
4031+
assert_eq!(attempts.load(Ordering::SeqCst), 3);
4032+
4033+
let denied_attempts = AtomicUsize::new(0);
4034+
let denied = retry_within_budget(BUDGET, ATTEMPT, INTERVAL, || {
4035+
denied_attempts.fetch_add(1, Ordering::SeqCst);
4036+
async { Err(BackendError::Terminated("terminating".to_string())) }
4037+
})
4038+
.await;
4039+
assert!(matches!(denied, Err(BackendError::Terminated(_))));
4040+
assert_eq!(denied_attempts.load(Ordering::SeqCst), 1);
4041+
}
4042+
}

0 commit comments

Comments
 (0)