Skip to main content

agnt5_sdk_core/
client.rs

1use crate::error::{Result, SdkError};
2use crate::pb::{
3    engine_service_client::EngineServiceClient,
4    execution_engine_service_client::ExecutionEngineServiceClient,
5    worker_coordinator_service_client::WorkerCoordinatorServiceClient, AppendBatchRequest,
6    AppendBatchResponse, AppendRequest, BeginActivationRequest, BeginActivationResponse,
7    CheckpointRequest, CheckpointType, CompleteActivationRequest, CompleteActivationResponse,
8    CompleteJobRequest, CompleteJobResponse, DurableStepCheckpoint, EventStreamMessage,
9    FailActivationRequest, FailActivationResponse, FindByStepKeyRequest, GetEntityStateRequest,
10    GetEntityStateResponse, PollJobRequest, PollJobResponse, PutEntityStateRequest,
11    PutEntityStateResponse, Record, RegisterService, RegisterWorkerSessionRequest,
12    RegisterWorkerSessionResponse, RenewJobLeaseRequest, RenewJobLeaseResponse,
13    ReportWorkerCapacityRequest, ReportWorkerCapacityResponse, RuntimeMessage, ServiceMessage,
14    SuspendActivationRequest, SuspendActivationResponse,
15};
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::sync::{Arc, RwLock};
19use std::time::Duration;
20use tonic::metadata::MetadataValue;
21use tonic::service::interceptor::InterceptedService;
22use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint};
23use tonic::Code;
24use tonic::{Request, Status};
25use tracing::{debug, error};
26
27const EXTERNAL_DISCOVERY_PATH: &str = "/api/v1/worker-discovery";
28const EXTERNAL_TOKEN_PATH: &str = "/api/v1/worker-token";
29const AUTH_PROFILE_BOOTSTRAP_MTLS: &str = "bootstrap-mtls";
30const AUTH_PROFILE_TOKEN_AUTH: &str = "token-auth";
31
32#[derive(Clone, Debug)]
33struct BearerInterceptor {
34    token: Arc<RwLock<Option<MetadataValue<tonic::metadata::Ascii>>>>,
35}
36
37impl tonic::service::Interceptor for BearerInterceptor {
38    fn call(&mut self, mut request: Request<()>) -> std::result::Result<Request<()>, Status> {
39        if let Some(token) = self.token.read().unwrap_or_else(|p| p.into_inner()).clone() {
40            request.metadata_mut().insert("authorization", token);
41        }
42        Ok(request)
43    }
44}
45
46type AuthenticatedChannel = InterceptedService<Channel, BearerInterceptor>;
47
48#[derive(Clone, Debug, Deserialize)]
49struct ExternalWorkerAuthority {
50    project_id: String,
51    environment_id: String,
52    deployment_id: String,
53    worker_pool_id: String,
54    #[serde(default)]
55    runtime_endpoint: String,
56    #[serde(default)]
57    protocol: String,
58    #[serde(default = "default_external_worker_auth_profile")]
59    auth_profile: String,
60    #[serde(default)]
61    identity_endpoint: String,
62}
63
64fn default_external_worker_auth_profile() -> String {
65    AUTH_PROFILE_TOKEN_AUTH.to_string()
66}
67
68fn external_worker_ca_bundle() -> Result<Option<Vec<u8>>> {
69    crate::external_worker_identity::server_ca_pem()
70}
71
72fn configure_channel_tls(
73    endpoint: Endpoint,
74    tls: Option<ClientTlsConfig>,
75) -> std::result::Result<Endpoint, tonic::transport::Error> {
76    let tls = match tls {
77        Some(tls) => Some(tls),
78        None if endpoint.uri().scheme_str() == Some("https") => {
79            Some(ClientTlsConfig::new().with_webpki_roots())
80        }
81        None => None,
82    };
83    match tls {
84        Some(tls) => endpoint.tls_config(tls),
85        None => Ok(endpoint),
86    }
87}
88
89impl ExternalWorkerAuthority {
90    fn apply_to_metadata(&self, metadata: &mut HashMap<String, String>) {
91        metadata.insert("project_id".to_string(), self.project_id.clone());
92        metadata.insert("environment_id".to_string(), self.environment_id.clone());
93        metadata.insert("deployment_id".to_string(), self.deployment_id.clone());
94        metadata.insert("worker_pool_id".to_string(), self.worker_pool_id.clone());
95    }
96}
97
98#[derive(Debug, Serialize)]
99struct ExternalWorkerTokenRequest<'a> {
100    project_id: &'a str,
101    environment_id: &'a str,
102    deployment_id: &'a str,
103    worker_pool_id: &'a str,
104}
105
106impl<'a> From<&'a ExternalWorkerAuthority> for ExternalWorkerTokenRequest<'a> {
107    fn from(authority: &'a ExternalWorkerAuthority) -> Self {
108        Self {
109            project_id: &authority.project_id,
110            environment_id: &authority.environment_id,
111            deployment_id: &authority.deployment_id,
112            worker_pool_id: &authority.worker_pool_id,
113        }
114    }
115}
116
117#[derive(Clone, Debug, Deserialize)]
118struct ExternalWorkerTokenResponse {
119    workload_token: String,
120    expires_at: chrono::DateTime<chrono::Utc>,
121}
122
123#[derive(Clone, Debug)]
124struct ExternalWorkerBootstrap {
125    control_plane_url: String,
126    credential: String,
127    authority: ExternalWorkerAuthority,
128}
129
130pub(crate) fn remote_worker_bootstrap_enabled() -> bool {
131    std::env::var("AGNT5_API_KEY_FILE").is_ok_and(|value| !value.trim().is_empty())
132}
133
134async fn external_worker_bootstrap() -> Result<(
135    String,
136    BearerInterceptor,
137    Option<ClientTlsConfig>,
138    Option<String>,
139    ExternalWorkerAuthority,
140)> {
141    let control_plane_url = std::env::var("AGNT5_CONTROL_PLANE_URL")
142        .unwrap_or_else(|_| "https://api.agnt5.com".to_string())
143        .trim_end_matches('/')
144        .to_string();
145    crate::external_worker_identity::validate_identity_endpoint(&control_plane_url)?;
146    let key_path = std::env::var("AGNT5_API_KEY_FILE").map_err(|_| SdkError::Connection {
147        message: "AGNT5_API_KEY_FILE is required for external workers".to_string(),
148        code: crate::error::ErrorCode::ConnectionFailed,
149        source: None,
150    })?;
151    let credential = std::fs::read_to_string(&key_path)
152        .map_err(|error| SdkError::Connection {
153            message: format!("read external worker credential {}: {}", key_path, error),
154            code: crate::error::ErrorCode::ConnectionFailed,
155            source: None,
156        })?
157        .trim()
158        .to_string();
159    if credential.is_empty() {
160        return Err(SdkError::Connection {
161            message: "external worker credential file is empty".to_string(),
162            code: crate::error::ErrorCode::ConnectionFailed,
163            source: None,
164        });
165    }
166    let environment = std::env::var("AGNT5_ENVIRONMENT").unwrap_or_default();
167    let ca_bundle = external_worker_ca_bundle()?;
168    let http = crate::external_worker_identity::bootstrap_http_client()?;
169    let rollout = crate::external_worker_identity::RolloutReadiness::from_env()?;
170    let response = http
171        .post(format!("{}{}", control_plane_url, EXTERNAL_DISCOVERY_PATH))
172        .header("X-API-KEY", &credential)
173        .json(&serde_json::json!({
174            "environment": environment,
175            "supported_auth_profiles": rollout.profiles(),
176            "mtls_ready": rollout.ready,
177            "current_auth_profile": rollout.current_profile()
178        }))
179        .send()
180        .await
181        .map_err(|error| SdkError::Connection {
182            message: format!("external worker discovery failed: {}", error),
183            code: crate::error::ErrorCode::ConnectionFailed,
184            source: None,
185        })?;
186    if !response.status().is_success() {
187        return Err(SdkError::Connection {
188            message: format!("external worker discovery returned {}", response.status()),
189            code: crate::error::ErrorCode::ConnectionFailed,
190            source: None,
191        });
192    }
193    let authority: ExternalWorkerAuthority =
194        response
195            .json()
196            .await
197            .map_err(|error| SdkError::Connection {
198                message: format!("decode external worker discovery response: {}", error),
199                code: crate::error::ErrorCode::ConnectionFailed,
200                source: None,
201            })?;
202    if authority.protocol != "pull.v1" || authority.runtime_endpoint.trim().is_empty() {
203        return Err(SdkError::Connection {
204            message: "external worker discovery returned an unsupported target".to_string(),
205            code: crate::error::ErrorCode::ConnectionFailed,
206            source: None,
207        });
208    }
209    rollout.accept(&authority.auth_profile)?;
210    match authority.auth_profile.as_str() {
211        AUTH_PROFILE_BOOTSTRAP_MTLS => {
212            if authority.identity_endpoint.trim().is_empty() {
213                return Err(SdkError::Connection {
214                    message:
215                        "worker discovery selected bootstrap-mtls without an identity endpoint"
216                            .to_string(),
217                    code: crate::error::ErrorCode::ConnectionFailed,
218                    source: None,
219                });
220            }
221            let identity_authority = crate::external_worker_identity::Authority {
222                project_id: authority.project_id.clone(),
223                environment_id: authority.environment_id.clone(),
224                deployment_id: authority.deployment_id.clone(),
225                worker_pool_id: authority.worker_pool_id.clone(),
226                runtime_endpoint: authority.runtime_endpoint.clone(),
227                protocol: authority.protocol.clone(),
228            };
229            let identity = crate::external_worker_identity::connection_identity(
230                control_plane_url,
231                authority.identity_endpoint.clone(),
232                credential,
233                identity_authority,
234            )
235            .await?;
236            eprintln!("[INFO] worker authenticated with bootstrap mTLS");
237            return Ok((
238                identity.endpoint,
239                BearerInterceptor {
240                    token: identity.authorization,
241                },
242                Some(identity.tls),
243                Some(identity.worker_id),
244                authority,
245            ));
246        }
247        AUTH_PROFILE_TOKEN_AUTH => {}
248        profile => {
249            return Err(SdkError::Connection {
250                message: format!(
251                    "worker discovery selected unsupported authentication profile {profile:?}"
252                ),
253                code: crate::error::ErrorCode::ConnectionFailed,
254                source: None,
255            });
256        }
257    }
258    let bootstrap = ExternalWorkerBootstrap {
259        control_plane_url,
260        credential,
261        authority,
262    };
263    let initial = exchange_external_worker_token(&http, &bootstrap).await?;
264    let token = bearer_metadata(&initial.workload_token)?;
265    let shared = Arc::new(RwLock::new(Some(token)));
266    spawn_external_worker_token_refresh(
267        http,
268        bootstrap.clone(),
269        shared.clone(),
270        initial.expires_at,
271    );
272
273    eprintln!("[INFO] worker authenticated with token auth");
274    eprintln!(
275        "[INFO] discovered runtime endpoint ({})",
276        bootstrap.authority.runtime_endpoint
277    );
278    let mut runtime_tls = ClientTlsConfig::new().with_webpki_roots();
279    if let Some(pem) = ca_bundle {
280        runtime_tls = runtime_tls.ca_certificate(Certificate::from_pem(pem));
281    }
282    Ok((
283        bootstrap.authority.runtime_endpoint.clone(),
284        BearerInterceptor { token: shared },
285        Some(runtime_tls),
286        None,
287        bootstrap.authority,
288    ))
289}
290
291async fn exchange_external_worker_token(
292    http: &reqwest::Client,
293    bootstrap: &ExternalWorkerBootstrap,
294) -> Result<ExternalWorkerTokenResponse> {
295    let response = http
296        .post(format!(
297            "{}{}",
298            bootstrap.control_plane_url, EXTERNAL_TOKEN_PATH
299        ))
300        .header("X-API-KEY", &bootstrap.credential)
301        .json(&ExternalWorkerTokenRequest::from(&bootstrap.authority))
302        .send()
303        .await
304        .map_err(|error| SdkError::Connection {
305            message: format!("external worker token exchange failed: {}", error),
306            code: crate::error::ErrorCode::ConnectionFailed,
307            source: None,
308        })?;
309    if !response.status().is_success() {
310        return Err(SdkError::Connection {
311            message: format!(
312                "external worker token exchange returned {}",
313                response.status()
314            ),
315            code: crate::error::ErrorCode::ConnectionFailed,
316            source: None,
317        });
318    }
319    response.json().await.map_err(|error| SdkError::Connection {
320        message: format!("decode external worker token response: {}", error),
321        code: crate::error::ErrorCode::ConnectionFailed,
322        source: None,
323    })
324}
325
326fn bearer_metadata(token: &str) -> Result<MetadataValue<tonic::metadata::Ascii>> {
327    format!("Bearer {}", token)
328        .parse()
329        .map_err(|error| SdkError::Connection {
330            message: format!("invalid external worker token metadata: {}", error),
331            code: crate::error::ErrorCode::ConnectionFailed,
332            source: None,
333        })
334}
335
336fn spawn_external_worker_token_refresh(
337    http: reqwest::Client,
338    bootstrap: ExternalWorkerBootstrap,
339    token: Arc<RwLock<Option<MetadataValue<tonic::metadata::Ascii>>>>,
340    mut expires_at: chrono::DateTime<chrono::Utc>,
341) {
342    tokio::spawn(async move {
343        loop {
344            let delay = (expires_at - chrono::Utc::now() - chrono::Duration::seconds(60))
345                .to_std()
346                .unwrap_or(Duration::from_secs(1));
347            tokio::time::sleep(delay).await;
348            match exchange_external_worker_token(&http, &bootstrap).await {
349                Ok(refreshed) => match bearer_metadata(&refreshed.workload_token) {
350                    Ok(value) => {
351                        *token.write().unwrap_or_else(|p| p.into_inner()) = Some(value);
352                        expires_at = refreshed.expires_at;
353                    }
354                    Err(error) => error!("External worker token refresh rejected: {}", error),
355                },
356                Err(error) => {
357                    error!("External worker token refresh failed: {}; retrying", error);
358                    tokio::time::sleep(Duration::from_secs(5)).await;
359                }
360            }
361        }
362    });
363}
364
365pub const DURABLE_ACTIVATION_V1_CAPABILITY: &str = "durable_activation_v1";
366pub const DURABLE_SUSPENSION_V1_CAPABILITY: &str = "durable_suspension_v1";
367/// Lets a non-streaming pull job carry its lifecycle records inside
368/// `CompleteJob` instead of appending them one RPC at a time. Always optional;
369/// `AGNT5_PULL_COMPLETION_LIFECYCLE=disabled` stops advertising it.
370pub const PULL_COMPLETION_LIFECYCLE_V1_CAPABILITY: &str = "pull_completion_lifecycle_v1";
371/// Enables authoritative slot feedback in `PollJobResponse.slot_scaling`.
372/// Optional so new workers preserve local scaling against older runtimes.
373pub const SERVER_SLOT_SCALING_V1_CAPABILITY: &str = "server_slot_scaling_v1";
374
375fn pull_completion_lifecycle_enabled() -> bool {
376    !std::env::var("AGNT5_PULL_COMPLETION_LIFECYCLE")
377        .map(|value| value.trim().eq_ignore_ascii_case("disabled"))
378        .unwrap_or(false)
379}
380
381pub fn worker_protocol_capabilities() -> (Vec<String>, Vec<String>) {
382    let (mut supported, required) = match std::env::var("AGNT5_DURABLE_ACTIVATION_MODE")
383        .unwrap_or_else(|_| "preferred".to_string())
384        .to_ascii_lowercase()
385        .as_str()
386    {
387        "disabled" => (Vec::new(), Vec::new()),
388        "required" => (
389            vec![
390                DURABLE_ACTIVATION_V1_CAPABILITY.to_string(),
391                DURABLE_SUSPENSION_V1_CAPABILITY.to_string(),
392            ],
393            vec![DURABLE_ACTIVATION_V1_CAPABILITY.to_string()],
394        ),
395        _ => (
396            vec![
397                DURABLE_ACTIVATION_V1_CAPABILITY.to_string(),
398                DURABLE_SUSPENSION_V1_CAPABILITY.to_string(),
399            ],
400            Vec::new(),
401        ),
402    };
403    if pull_completion_lifecycle_enabled() {
404        supported.push(PULL_COMPLETION_LIFECYCLE_V1_CAPABILITY.to_string());
405    }
406    supported.push(SERVER_SLOT_SCALING_V1_CAPABILITY.to_string());
407    (supported, required)
408}
409
410pub(crate) fn validate_protocol_capabilities(
411    worker_supported: &[String],
412    worker_required: &[String],
413    runtime_supported: &[String],
414    runtime_required: &[String],
415) -> Result<()> {
416    if let Some(required) = worker_required
417        .iter()
418        .find(|required| !runtime_supported.contains(required))
419    {
420        return Err(SdkError::Activation {
421            message: format!("runtime did not negotiate required protocol capability: {required}"),
422            code: crate::error::ErrorCode::DurabilityUnavailable,
423            activation_id: None,
424            attempt: None,
425        });
426    }
427    if let Some(required) = runtime_required
428        .iter()
429        .find(|required| !worker_supported.contains(required))
430    {
431        return Err(SdkError::Activation {
432            message: format!("runtime requires unsupported worker protocol capability: {required}"),
433            code: crate::error::ErrorCode::DurabilityUnavailable,
434            activation_id: None,
435            attempt: None,
436        });
437    }
438    if worker_supported
439        .iter()
440        .any(|capability| capability == DURABLE_ACTIVATION_V1_CAPABILITY)
441        && !worker_required
442            .iter()
443            .any(|capability| capability == DURABLE_ACTIVATION_V1_CAPABILITY)
444        && !runtime_supported
445            .iter()
446            .any(|capability| capability == DURABLE_ACTIVATION_V1_CAPABILITY)
447    {
448        eprintln!(
449            "[WARN] agnt5 durable activation degraded: runtime did not advertise durable_activation_v1; legacy checkpoints remain enabled"
450        );
451    }
452    Ok(())
453}
454
455fn negotiated_protocol_capabilities(
456    worker_supported: &[String],
457    runtime_supported: &[String],
458) -> Vec<String> {
459    worker_supported
460        .iter()
461        .filter(|capability| runtime_supported.contains(capability))
462        .fold(Vec::new(), |mut negotiated, capability| {
463            if !negotiated.contains(capability) {
464                negotiated.push(capability.clone());
465            }
466            negotiated
467        })
468}
469
470fn activation_status(operation: &str, status: tonic::Status) -> SdkError {
471    crate::runtime_adapter::activation_status_error(operation, status)
472}
473
474/// Simple client for communicating with the Worker Coordinator service.
475///
476/// Holds two gRPC clients multiplexed over the same `tonic::Channel`:
477/// - `client`: WorkerCoordinatorService (worker registration, dispatch streaming)
478/// - `engine_client`: EngineService (durable execution: checkpoint, event stream,
479///   parked job polling/complete, memoization lookup via find_by_step_key)
480///
481/// The durable execution RPCs used to live on WorkerCoordinatorService and moved
482/// to EngineService as part of the journal-owner consolidation. Both clients
483/// share one HTTP/2 connection since `tonic::Channel` is cheap to clone and
484/// multiplexes streams.
485#[derive(Debug, Clone)]
486pub struct WorkerCoordinatorClient {
487    client: WorkerCoordinatorServiceClient<AuthenticatedChannel>,
488    engine_client: EngineServiceClient<AuthenticatedChannel>,
489    negotiated_protocol_capabilities: Arc<RwLock<Vec<String>>>,
490    authoritative_worker_id: Option<String>,
491    external_worker_authority: Option<ExternalWorkerAuthority>,
492    identity_authorization: Option<Arc<RwLock<Option<MetadataValue<tonic::metadata::Ascii>>>>>,
493}
494
495async fn wait_for_identity_rotation(
496    authorization: Option<&Arc<RwLock<Option<MetadataValue<tonic::metadata::Ascii>>>>>,
497) {
498    let Some(authorization) = authorization else {
499        return std::future::pending().await;
500    };
501    loop {
502        if authorization
503            .read()
504            .unwrap_or_else(|p| p.into_inner())
505            .is_none()
506        {
507            return;
508        }
509        tokio::time::sleep(Duration::from_millis(100)).await;
510    }
511}
512
513const WORKER_COORDINATOR_RPC_TIMEOUT: Duration = Duration::from_secs(45);
514const PARKED_POLL_CLIENT_GRACE: Duration = Duration::from_secs(5);
515const MAX_PARKED_POLL_WAIT_MS: i64 = 30_000;
516
517fn poll_job_deadline(req: &PollJobRequest) -> Duration {
518    let wait_ms = req.wait_ms.clamp(0, MAX_PARKED_POLL_WAIT_MS) as u64;
519    Duration::from_millis(wait_ms).saturating_add(PARKED_POLL_CLIENT_GRACE)
520}
521
522fn is_idle_poll_timeout(status: &tonic::Status) -> bool {
523    matches!(status.code(), Code::Cancelled | Code::DeadlineExceeded)
524        && status.message().to_ascii_lowercase().contains("timeout")
525}
526
527impl WorkerCoordinatorClient {
528    /// Create a new client connected to the Worker Coordinator
529    pub async fn connect(endpoint: String) -> Result<Self> {
530        let (endpoint, interceptor, tls, authoritative_worker_id, external_worker_authority) =
531            if remote_worker_bootstrap_enabled() {
532                let (endpoint, interceptor, tls, worker_id, authority) =
533                    external_worker_bootstrap().await?;
534                (endpoint, interceptor, tls, worker_id, Some(authority))
535            } else {
536                (
537                    endpoint,
538                    BearerInterceptor {
539                        token: Arc::new(RwLock::new(None)),
540                    },
541                    None,
542                    None,
543                    None,
544                )
545            };
546        debug!("Connecting to Worker Coordinator at {}", endpoint);
547
548        let channel = Channel::from_shared(endpoint.clone()).map_err(|e| SdkError::Connection {
549            message: format!("Invalid endpoint {}: {}", endpoint, e),
550            code: crate::error::ErrorCode::ConnectionFailed,
551            source: None,
552        })?;
553        let channel = configure_channel_tls(channel, tls).map_err(|e| SdkError::Connection {
554            message: format!("Invalid worker TLS configuration: {}", e),
555            code: crate::error::ErrorCode::ConnectionFailed,
556            source: None,
557        })?;
558        let channel = channel
559            .connect_timeout(Duration::from_secs(10))
560            .timeout(WORKER_COORDINATOR_RPC_TIMEOUT)
561            .http2_adaptive_window(true)
562            .connect()
563            .await
564            .map_err(|e| {
565                // Expected during reconnection — debug level to avoid noisy logs
566                debug!("Connection to {} failed: {:?}", endpoint, e);
567                e
568            })?;
569
570        let client =
571            WorkerCoordinatorServiceClient::with_interceptor(channel.clone(), interceptor.clone());
572        let identity_authorization = authoritative_worker_id
573            .as_ref()
574            .map(|_| interceptor.token.clone());
575        let engine_client = EngineServiceClient::with_interceptor(channel, interceptor);
576
577        Ok(Self {
578            client,
579            engine_client,
580            negotiated_protocol_capabilities: Arc::new(RwLock::new(Vec::new())),
581            authoritative_worker_id,
582            external_worker_authority,
583            identity_authorization,
584        })
585    }
586
587    /// Rotation invalidates the old certificate-bound token. Pull workers have
588    /// no dispatch stream to close, so observe that invalidation explicitly.
589    pub(crate) async fn wait_for_identity_rotation(&self) {
590        wait_for_identity_rotation(self.identity_authorization.as_ref()).await;
591    }
592
593    /// Apply the project-bound authority returned by external worker discovery.
594    /// Discovery is authoritative, so these values intentionally replace any
595    /// same-named metadata supplied before the connection was established.
596    pub(crate) fn apply_external_worker_authority(&self, metadata: &mut HashMap<String, String>) {
597        let Some(authority) = &self.external_worker_authority else {
598            return;
599        };
600        authority.apply_to_metadata(metadata);
601    }
602
603    pub(crate) fn is_external_worker(&self) -> bool {
604        self.external_worker_authority.is_some()
605    }
606
607    /// Use the server-assigned ID for certificate-bound customer workers.
608    pub(crate) fn effective_worker_id<'a>(&'a self, configured: &'a str) -> &'a str {
609        self.authoritative_worker_id
610            .as_deref()
611            .unwrap_or(configured)
612    }
613
614    pub(crate) fn retain_negotiated_protocol_capabilities(
615        &self,
616        worker_supported: &[String],
617        runtime_supported: &[String],
618    ) {
619        let negotiated = negotiated_protocol_capabilities(worker_supported, runtime_supported);
620        let mut capabilities = self
621            .negotiated_protocol_capabilities
622            .write()
623            .unwrap_or_else(|poisoned| poisoned.into_inner());
624        *capabilities = negotiated;
625    }
626
627    /// Return whether this worker session actually negotiated a protocol
628    /// capability with the runtime. Clones share the same session state.
629    pub fn negotiated_protocol_capability(&self, capability: &str) -> bool {
630        self.negotiated_protocol_capabilities
631            .read()
632            .unwrap_or_else(|poisoned| poisoned.into_inner())
633            .iter()
634            .any(|negotiated| negotiated == capability)
635    }
636
637    /// Create a worker stream with immediate registration (based on working pattern)
638    pub async fn create_worker_stream_with_registration(
639        &mut self,
640        worker_id: String,
641        registration: RegisterService,
642    ) -> Result<(
643        flume::Sender<ServiceMessage>,
644        flume::Receiver<RuntimeMessage>,
645    )> {
646        let worker_id = self.effective_worker_id(&worker_id).to_string();
647        let worker_supported_protocols = registration.supported_protocol_capabilities.clone();
648        let worker_required_protocols = registration.required_protocol_capabilities.clone();
649        // Create the registration message first
650        let registration_message = ServiceMessage {
651            worker_id: worker_id.clone(),
652            metadata: HashMap::new(),
653            message_type: Some(crate::pb::service_message::MessageType::RegisterService(
654                registration,
655            )),
656        };
657
658        // Create bounded channels for ongoing communication (reasonable default capacity)
659        let (outgoing_tx, outgoing_rx) = flume::bounded::<ServiceMessage>(1000);
660        let (runtime_msg_tx, runtime_msg_rx) = flume::bounded::<RuntimeMessage>(1000);
661
662        // Create stream that yields registration immediately, then handles ongoing messages
663        let outgoing_stream = async_stream::stream! {
664            // First, yield the registration message immediately
665            yield registration_message;
666
667            // Then, handle ongoing messages from the channel
668            loop {
669                match outgoing_rx.recv_async().await {
670                    Ok(msg) => {
671                        yield msg;
672                    },
673                    Err(_) => {
674                        break;
675                    }
676                }
677            }
678        };
679
680        // Expose the worker ID as gRPC metadata so L7 proxies can route
681        // reconnects consistently before reading the protobuf stream body.
682        let worker_id_header = tonic::metadata::MetadataValue::try_from(worker_id.as_str())
683            .map_err(|e| SdkError::Connection {
684                message: format!("Invalid worker_id for routing metadata: {}", e),
685                code: crate::error::ErrorCode::ConnectionFailed,
686                source: None,
687            })?;
688        let mut request = tonic::Request::new(outgoing_stream);
689        request
690            .metadata_mut()
691            .insert("x-agnt5-worker-id", worker_id_header);
692
693        // Establish the gRPC stream
694        let mut response_stream = self
695            .client
696            .worker_stream(request)
697            .await
698            .map_err(|e| {
699                debug!("Failed to create gRPC worker stream: {}", e);
700                SdkError::Connection {
701                    message: format!("gRPC stream failed: {}", e),
702                    code: crate::error::ErrorCode::ConnectionFailed,
703                    source: None,
704                }
705            })?
706            .into_inner();
707
708        let registration_response =
709            tokio::time::timeout(Duration::from_secs(10), response_stream.message())
710                .await
711                .map_err(|_| {
712                    error!("Timeout waiting for registration response");
713                    SdkError::Connection {
714                        message: "Registration timeout - no response from runtime".to_string(),
715                        code: crate::error::ErrorCode::ConnectionTimeout,
716                        source: None,
717                    }
718                })?
719                .map_err(|e| {
720                    debug!("Failed to receive registration response: {}", e);
721                    SdkError::Connection {
722                        message: format!("Stream error: {}", e),
723                        code: crate::error::ErrorCode::ConnectionFailed,
724                        source: None,
725                    }
726                })?;
727
728        // Process registration response. Note: the runtime no longer
729        // emits redirect NACKs (any serving coordinator accepts any
730        // worker), so we only need to handle ack=true and outright
731        // failures here.
732        if let Some(runtime_message) = registration_response {
733            match &runtime_message.message_data {
734                Some(crate::pb::runtime_message::MessageData::RegisterServiceResponse(resp)) => {
735                    if !resp.ack {
736                        error!("Registration failed: {}", resp.error);
737                        return Err(SdkError::Connection {
738                            message: format!("Registration failed: {}", resp.error),
739                            code: crate::error::ErrorCode::ConnectionFailed,
740                            source: None,
741                        });
742                    }
743                    validate_protocol_capabilities(
744                        &worker_supported_protocols,
745                        &worker_required_protocols,
746                        &resp.supported_protocol_capabilities,
747                        &resp.required_protocol_capabilities,
748                    )?;
749                    self.retain_negotiated_protocol_capabilities(
750                        &worker_supported_protocols,
751                        &resp.supported_protocol_capabilities,
752                    );
753                }
754                _ => {
755                    error!("Unexpected response type to registration");
756                    return Err(SdkError::Connection {
757                        message: "Unexpected response to registration".to_string(),
758                        code: crate::error::ErrorCode::InvalidMessage,
759                        source: None,
760                    });
761                }
762            }
763        } else {
764            error!("No registration response received");
765            return Err(SdkError::Connection {
766                message: "No registration response received".to_string(),
767                code: crate::error::ErrorCode::ConnectionFailed,
768                source: None,
769            });
770        }
771
772        // Spawn simple task to forward stream messages to runtime channel
773        tokio::spawn(async move {
774            while let Some(message_result) =
775                tokio_stream::StreamExt::next(&mut response_stream).await
776            {
777                match message_result {
778                    Ok(runtime_message) => {
779                        if runtime_msg_tx.send_async(runtime_message).await.is_err() {
780                            break;
781                        }
782                    }
783                    Err(e) => {
784                        debug!("Stream closed: {}", e);
785                        break;
786                    }
787                }
788            }
789        });
790
791        Ok((outgoing_tx, runtime_msg_rx))
792    }
793
794    /// Open an EventStream for sending ephemeral events (SSE-only: tokens, progress, logs, spans).
795    ///
796    /// Returns a sender for EventStreamMessage. Events sent through this channel are published
797    /// to Centrifuge/Redis for real-time SSE delivery without journal persistence.
798    /// Drop the sender to close the stream.
799    pub async fn create_event_stream(
800        &mut self,
801        worker_id: String,
802    ) -> Result<flume::Sender<EventStreamMessage>> {
803        let (tx, rx) = flume::bounded::<EventStreamMessage>(1000);
804
805        let stream = async_stream::stream! {
806            loop {
807                match rx.recv_async().await {
808                    Ok(msg) => yield msg,
809                    Err(_) => break, // Sender dropped, close stream
810                }
811            }
812        };
813
814        // EventStream now lives on EngineService (moved from WorkerCoordinatorService).
815        let mut client = self.engine_client.clone();
816        tokio::spawn(async move {
817            match client.event_stream(stream).await {
818                Ok(response) => {
819                    let ack = response.into_inner();
820                    debug!(
821                        "EventStream closed: success={} events_received={}",
822                        ack.success, ack.events_received
823                    );
824                }
825                Err(e) => {
826                    debug!("EventStream error: {}", e);
827                }
828            }
829        });
830
831        debug!("EventStream opened for worker {}", worker_id);
832        Ok(tx)
833    }
834
835    /// Send a step checkpoint and check for memoized result
836    ///
837    /// This method sends a checkpoint for a workflow step and checks if the step
838    /// result is already memoized. If memoized, returns the cached output.
839    ///
840    /// # Arguments
841    ///
842    /// * `tenant_id` - Tenant the run belongs to (required for engine lookups)
843    /// * `run_id` - The workflow run ID
844    /// * `step_key` - Unique key for this step (e.g., "step:greet:0")
845    /// * `step_name` - Human-readable step name
846    /// * `step_type` - Type of step (e.g., "function", "activity", "llm_call")
847    /// * `checkpoint_type` - Type of checkpoint (started, completed, failed)
848    /// * `payload` - Checkpoint payload (input for started, output for completed)
849    /// * `error_message` - Error message (for failed checkpoints)
850    /// * `error_type` - Error type (for failed checkpoints)
851    /// * `latency_ms` - Step execution latency in milliseconds
852    ///
853    /// # Returns
854    ///
855    /// `CheckpointResult` containing memoization status and cached output if available
856    pub async fn checkpoint(
857        &mut self,
858        tenant_id: String,
859        run_id: String,
860        step_key: String,
861        step_name: String,
862        step_type: String,
863        checkpoint_type: CheckpointType,
864        payload: Option<Vec<u8>>,
865        error_message: Option<String>,
866        error_type: Option<String>,
867        latency_ms: Option<i64>,
868    ) -> Result<CheckpointResult> {
869        debug!(
870            "Sending checkpoint: tenant_id={}, run_id={}, step_key={}, type={:?}",
871            tenant_id, run_id, step_key, checkpoint_type
872        );
873
874        let checkpoint = DurableStepCheckpoint {
875            run_id,
876            step_key,
877            step_name,
878            step_type,
879            r#type: checkpoint_type.into(),
880            payload: payload.unwrap_or_default(),
881            error_message: error_message.unwrap_or_default(),
882            error_type: error_type.unwrap_or_default(),
883            tokens_in: 0,
884            tokens_out: 0,
885            cost_usd: 0.0,
886            latency_ms: latency_ms.unwrap_or(0),
887            model_provider: String::new(),
888            model_version: String::new(),
889        };
890
891        let request = CheckpointRequest {
892            checkpoint: Some(checkpoint),
893            project_id: tenant_id,
894        };
895
896        // Checkpoint moved from WorkerCoordinatorService → EngineService.
897        let response = self
898            .engine_client
899            .checkpoint(request)
900            .await
901            .map_err(|e| {
902                debug!("Checkpoint RPC failed: {}", e);
903                SdkError::Connection {
904                    message: format!("Checkpoint failed: {}", e),
905                    code: crate::error::ErrorCode::ConnectionFailed,
906                    source: None,
907                }
908            })?
909            .into_inner();
910
911        Ok(CheckpointResult {
912            success: response.success,
913            error_message: if response.error_message.is_empty() {
914                None
915            } else {
916                Some(response.error_message)
917            },
918            memoized: response.memoized,
919            cached_output: if response.cached_output.is_empty() {
920                None
921            } else {
922                Some(response.cached_output)
923            },
924        })
925    }
926
927    /// Check if a step result is memoized without sending a full checkpoint.
928    ///
929    /// Uses `EngineService.FindByStepKey` as the canonical memoization lookup
930    /// (replaces the legacy `WorkerCoordinatorService.GetMemoizedStep` RPC,
931    /// which has been removed).
932    ///
933    /// # Arguments
934    ///
935    /// * `tenant_id` - The tenant that owns the run (required by the engine's
936    ///   `(tenant_id, run_id)` cache key).
937    /// * `run_id` - The workflow run ID.
938    /// * `step_key` - Unique key for this step.
939    ///
940    /// # Returns
941    ///
942    /// `Some(output)` if the step is memoized, `None` otherwise. Returns the
943    /// record's `data` field (the completed step's journal payload).
944    pub async fn get_memoized_step(
945        &mut self,
946        tenant_id: String,
947        run_id: String,
948        step_key: String,
949    ) -> Result<Option<Vec<u8>>> {
950        debug!(
951            "Checking memoization: tenant_id={}, run_id={}, step_key={}",
952            tenant_id, run_id, step_key
953        );
954
955        let request = FindByStepKeyRequest {
956            project_id: tenant_id,
957            run_id,
958            step_key,
959        };
960
961        let response = self
962            .engine_client
963            .find_by_step_key(request)
964            .await
965            .map_err(|e| {
966                debug!("FindByStepKey RPC failed: {}", e);
967                SdkError::Connection {
968                    message: format!("FindByStepKey failed: {}", e),
969                    code: crate::error::ErrorCode::ConnectionFailed,
970                    source: None,
971                }
972            })?
973            .into_inner();
974
975        if response.found {
976            if let Some(record) = response.record {
977                if !record.data.is_empty() {
978                    return Ok(Some(record.data));
979                }
980            }
981        }
982        Ok(None)
983    }
984
985    /// Register a parked-poll worker session with the Engine.
986    pub async fn register_worker_session(
987        &mut self,
988        mut req: RegisterWorkerSessionRequest,
989    ) -> Result<RegisterWorkerSessionResponse> {
990        req.worker_id = self.effective_worker_id(&req.worker_id).to_string();
991        let response = self
992            .engine_client
993            .register_worker_session(req)
994            .await
995            .map_err(|e| {
996                debug!("RegisterWorkerSession RPC failed: {}", e);
997                SdkError::Connection {
998                    message: format!("RegisterWorkerSession failed: {}", e),
999                    code: crate::error::ErrorCode::ConnectionFailed,
1000                    source: None,
1001                }
1002            })?
1003            .into_inner();
1004
1005        Ok(response)
1006    }
1007
1008    /// Park one worker slot until a job is available or the Engine times out.
1009    pub async fn poll_job(&mut self, mut req: PollJobRequest) -> Result<PollJobResponse> {
1010        req.worker_id = self.effective_worker_id(&req.worker_id).to_string();
1011        let timeout = poll_job_deadline(&req);
1012        let mut request = tonic::Request::new(req);
1013        request.set_timeout(timeout);
1014        let response = match self.engine_client.poll_job(request).await {
1015            Ok(response) => response,
1016            Err(e) => {
1017                if is_idle_poll_timeout(&e) {
1018                    debug!("PollJob idle timeout: {}", e);
1019                    return Ok(PollJobResponse {
1020                        job: None,
1021                        slot_scaling: None,
1022                    });
1023                }
1024                debug!("PollJob RPC failed: {}", e);
1025                return Err(SdkError::Connection {
1026                    message: format!("PollJob failed: {}", e),
1027                    code: crate::error::ErrorCode::ConnectionFailed,
1028                    source: None,
1029                });
1030            }
1031        }
1032        .into_inner();
1033
1034        Ok(response)
1035    }
1036
1037    /// Read entity state through the same unary Engine service used by parked
1038    /// polling. This is request-scoped and does not require a worker stream.
1039    pub async fn get_entity_state(
1040        &mut self,
1041        req: GetEntityStateRequest,
1042    ) -> Result<GetEntityStateResponse> {
1043        self.engine_client
1044            .get_entity_state(req)
1045            .await
1046            .map(|response| response.into_inner())
1047            .map_err(|error| SdkError::Connection {
1048                message: format!("GetEntityState failed: {error}"),
1049                code: crate::error::ErrorCode::ConnectionFailed,
1050                source: None,
1051            })
1052    }
1053
1054    /// Persist entity state through a unary, lease-fenced Engine request.
1055    pub async fn put_entity_state(
1056        &mut self,
1057        req: PutEntityStateRequest,
1058    ) -> Result<PutEntityStateResponse> {
1059        self.engine_client
1060            .put_entity_state(req)
1061            .await
1062            .map(|response| response.into_inner())
1063            .map_err(|error| SdkError::Connection {
1064                message: format!("PutEntityState failed: {error}"),
1065                code: crate::error::ErrorCode::ConnectionFailed,
1066                source: None,
1067            })
1068    }
1069
1070    /// Renew an active push or pull execution lease.
1071    pub async fn renew_job_lease(
1072        &mut self,
1073        mut req: RenewJobLeaseRequest,
1074    ) -> Result<RenewJobLeaseResponse> {
1075        req.worker_id = self.effective_worker_id(&req.worker_id).to_string();
1076        let response = self
1077            .engine_client
1078            .renew_job_lease(req)
1079            .await
1080            .map_err(|e| {
1081                debug!("RenewJobLease RPC failed: {}", e);
1082                SdkError::Connection {
1083                    message: format!("RenewJobLease failed: {}", e),
1084                    code: crate::error::ErrorCode::ConnectionFailed,
1085                    source: None,
1086                }
1087            })?
1088            .into_inner();
1089
1090        Ok(response)
1091    }
1092
1093    /// Report current parked-poll capacity and active slot usage.
1094    pub async fn report_worker_capacity(
1095        &mut self,
1096        mut req: ReportWorkerCapacityRequest,
1097    ) -> Result<ReportWorkerCapacityResponse> {
1098        req.worker_id = self.effective_worker_id(&req.worker_id).to_string();
1099        let response = self
1100            .engine_client
1101            .report_worker_capacity(req)
1102            .await
1103            .map_err(|e| {
1104                debug!("ReportWorkerCapacity RPC failed: {}", e);
1105                SdkError::Connection {
1106                    message: format!("ReportWorkerCapacity failed: {}", e),
1107                    code: crate::error::ErrorCode::ConnectionFailed,
1108                    source: None,
1109                }
1110            })?
1111            .into_inner();
1112
1113        Ok(response)
1114    }
1115
1116    /// Report the result of a polled job back to the engine.
1117    /// Updates job_queue, run status, journal, and batch counters.
1118    ///
1119    /// CompleteJob now lives on EngineService (moved from WorkerCoordinatorService).
1120    pub async fn complete_job(
1121        &mut self,
1122        mut req: CompleteJobRequest,
1123    ) -> Result<CompleteJobResponse> {
1124        req.worker_id = self.effective_worker_id(&req.worker_id).to_string();
1125        let response = self
1126            .engine_client
1127            .complete_job(req)
1128            .await
1129            .map_err(|e| {
1130                error!("CompleteJob RPC failed: {}", e);
1131                SdkError::Connection {
1132                    message: format!("CompleteJob failed: {}", e),
1133                    code: crate::error::ErrorCode::ConnectionFailed,
1134                    source: None,
1135                }
1136            })?
1137            .into_inner();
1138
1139        Ok(response)
1140    }
1141
1142    /// Append journal records through the Engine RPC on the worker's own
1143    /// channel. Used when a held lifecycle bundle cannot ride in `CompleteJob`
1144    /// (over the cap, the capability was lost, or the job suspended instead).
1145    pub async fn append_records(&mut self, records: Vec<Record>) -> Result<()> {
1146        if records.is_empty() {
1147            return Ok(());
1148        }
1149        let expected = records.len();
1150        let response = self
1151            .engine_client
1152            .append_batch(AppendBatchRequest { records })
1153            .await
1154            .map_err(|status| engine_append_status_error("Engine AppendBatch", status))?
1155            .into_inner();
1156        validate_append_batch_response(&response, expected).map(|_| ())
1157    }
1158
1159    /// Suspend a polled job through the Engine RPC. Pull workers do not keep a
1160    /// bidirectional dispatch stream, so they cannot return this typed result
1161    /// through WorkerStream like push workers do.
1162    pub async fn suspend_activation(
1163        &mut self,
1164        req: SuspendActivationRequest,
1165    ) -> Result<SuspendActivationResponse> {
1166        self.engine_client
1167            .suspend_activation(req)
1168            .await
1169            .map(|response| response.into_inner())
1170            .map_err(|status| activation_status("SuspendActivation", status))
1171    }
1172}
1173
1174/// Open an EventStream on the Execution Engine for sending ephemeral events (SSE-only).
1175///
1176/// Same pattern as WC's create_event_stream but routes to EE, which is the single
1177/// SSE publisher. Drop the sender to close the stream.
1178pub async fn create_ee_event_stream(
1179    ee_client: &mut ExecutionEngineServiceClient<Channel>,
1180    worker_id: String,
1181) -> Result<flume::Sender<EventStreamMessage>> {
1182    let (tx, rx) = flume::bounded::<EventStreamMessage>(1000);
1183
1184    let stream = async_stream::stream! {
1185        loop {
1186            match rx.recv_async().await {
1187                Ok(msg) => yield msg,
1188                Err(_) => break, // Sender dropped, close stream
1189            }
1190        }
1191    };
1192
1193    let mut client = ee_client.clone();
1194    tokio::spawn(async move {
1195        match client.event_stream(stream).await {
1196            Ok(response) => {
1197                let ack = response.into_inner();
1198                debug!(
1199                    "EE EventStream closed: success={} events_received={}",
1200                    ack.success, ack.events_received
1201                );
1202            }
1203            Err(e) => {
1204                debug!("EE EventStream error: {}", e);
1205            }
1206        }
1207    });
1208
1209    debug!("EE EventStream opened for worker {}", worker_id);
1210    Ok(tx)
1211}
1212
1213// =============================================================================
1214// Engine Client — routes events to the AGNT5 Rust engine (Append/AppendBatch)
1215// =============================================================================
1216
1217/// Pool size for engine gRPC connections.
1218/// Each connection is an independent h2 session, distributing load to avoid
1219/// the h2 PoisonError that occurs when 100+ concurrent requests share one connection.
1220const ENGINE_POOL_SIZE: usize = 8;
1221const ENGINE_RPC_RETRY_ATTEMPTS: usize = 20;
1222const ENGINE_ACTIVATION_RPC_ATTEMPTS: usize = 6;
1223const ENGINE_RPC_RETRY_DELAY: Duration = Duration::from_millis(100);
1224
1225pub(crate) fn is_retryable_engine_status(status: &tonic::Status) -> bool {
1226    let message = status.message().to_ascii_lowercase();
1227    matches!(
1228        status.code(),
1229        tonic::Code::Unavailable
1230            | tonic::Code::DeadlineExceeded
1231            | tonic::Code::Cancelled
1232            | tonic::Code::Unknown
1233    ) || is_retryable_engine_message(&message)
1234}
1235
1236pub(crate) fn engine_append_status_error(operation: &str, status: tonic::Status) -> SdkError {
1237    SdkError::Connection {
1238        message: format!("{operation} failed: {status}"),
1239        code: crate::error::ErrorCode::ConnectionFailed,
1240        source: Some(Box::new(status)),
1241    }
1242}
1243
1244fn is_retryable_engine_message(message: &str) -> bool {
1245    message.contains("upstream connect error")
1246        || message.contains("disconnect/reset before headers")
1247        || message.contains("connection termination")
1248        || message.contains("connection refused")
1249        || message.contains("broken pipe")
1250        || message.contains("h2 protocol error")
1251        || message.contains("timeout expired")
1252        || message.contains("no sequencer available")
1253        || message.contains("this node is not the sequencer")
1254        || message.contains("stale epoch")
1255        || message.contains("future epoch")
1256        || message.contains("epoch ahead")
1257        || message.contains("not partition owner")
1258        || message.contains("partition not writable")
1259        || message.contains("quorum not reached")
1260        || message.contains("no connected peers for quorum replication")
1261        || message.contains("not connected for catch-up")
1262        || message.contains("catch-up failed")
1263}
1264
1265fn validate_append_batch_records(records: &[Record]) -> Result<()> {
1266    let Some(first) = records.first() else {
1267        return Ok(());
1268    };
1269    if records
1270        .iter()
1271        .skip(1)
1272        .any(|record| record.run_id != first.run_id)
1273    {
1274        return Err(SdkError::InvalidArgument {
1275            message: "AppendBatch records must share one run_id so the request cannot span journal partitions".to_string(),
1276            argument: Some("records".to_string()),
1277        });
1278    }
1279    Ok(())
1280}
1281
1282fn validate_append_batch_response(response: &AppendBatchResponse, expected: usize) -> Result<i32> {
1283    if response.offsets.len() != expected {
1284        return Err(SdkError::Internal(format!(
1285            "Engine AppendBatch response cardinality mismatch: expected {expected}, offsets={}",
1286            response.offsets.len()
1287        )));
1288    }
1289    if response.written_count < 0 || response.written_count as usize > expected {
1290        return Err(SdkError::Internal(format!(
1291            "Engine AppendBatch written_count out of range: expected 0..={expected}, got {}",
1292            response.written_count
1293        )));
1294    }
1295    Ok(response.written_count)
1296}
1297
1298async fn sleep_engine_retry(attempt: usize) {
1299    let multiplier = (attempt + 1) as u32;
1300    tokio::time::sleep(ENGINE_RPC_RETRY_DELAY * multiplier).await;
1301}
1302
1303fn should_retry_activation_status(status: &tonic::Status, attempt: usize) -> bool {
1304    attempt + 1 < ENGINE_ACTIVATION_RPC_ATTEMPTS
1305        && status.details().is_empty()
1306        && is_retryable_engine_status(status)
1307}
1308
1309/// Client for communicating with the AGNT5 Engine.
1310///
1311/// Uses a pool of N independent gRPC connections with round-robin selection.
1312/// This prevents the h2 PoisonError that occurs when many concurrent checkpoint
1313/// events are routed through a single HTTP/2 connection.
1314#[derive(Debug, Clone)]
1315pub struct EngineClient {
1316    clients: Vec<EngineServiceClient<AuthenticatedChannel>>,
1317    next: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1318    authoritative_worker_id: Option<String>,
1319    identity_authorization: Option<Arc<RwLock<Option<MetadataValue<tonic::metadata::Ascii>>>>>,
1320    endpoint: String,
1321}
1322
1323impl EngineClient {
1324    /// Connect to the engine at the given endpoint with a pool of connections.
1325    pub async fn connect(endpoint: &str) -> Result<Self> {
1326        let (endpoint, interceptor, tls, authoritative_worker_id) =
1327            if remote_worker_bootstrap_enabled() {
1328                let (endpoint, interceptor, tls, worker_id, _authority) =
1329                    external_worker_bootstrap().await?;
1330                (endpoint, interceptor, tls, worker_id)
1331            } else {
1332                (
1333                    endpoint.to_string(),
1334                    BearerInterceptor {
1335                        token: Arc::new(RwLock::new(None)),
1336                    },
1337                    None,
1338                    None,
1339                )
1340            };
1341        debug!(
1342            "Connecting to Engine at {} (pool_size={})",
1343            endpoint, ENGINE_POOL_SIZE
1344        );
1345
1346        let uri = if endpoint.contains("://") {
1347            endpoint.clone()
1348        } else {
1349            format!("http://{}", endpoint)
1350        };
1351
1352        let mut clients = Vec::with_capacity(ENGINE_POOL_SIZE);
1353        for i in 0..ENGINE_POOL_SIZE {
1354            let channel = Channel::from_shared(uri.clone()).map_err(|e| SdkError::Connection {
1355                message: format!("Invalid engine endpoint {}: {}", endpoint, e),
1356                code: crate::error::ErrorCode::ConnectionFailed,
1357                source: None,
1358            })?;
1359            let channel =
1360                configure_channel_tls(channel, tls.clone()).map_err(|e| SdkError::Connection {
1361                    message: format!("Invalid worker TLS configuration: {}", e),
1362                    code: crate::error::ErrorCode::ConnectionFailed,
1363                    source: None,
1364                })?;
1365            let channel = channel
1366                .connect_timeout(Duration::from_secs(10))
1367                .timeout(Duration::from_secs(30))
1368                .http2_adaptive_window(true)
1369                .connect()
1370                .await
1371                .map_err(|e| {
1372                    debug!("Engine connection {} to {} failed: {:?}", i, endpoint, e);
1373                    SdkError::Connection {
1374                        message: format!("Engine connection failed: {}", e),
1375                        code: crate::error::ErrorCode::ConnectionFailed,
1376                        source: None,
1377                    }
1378                })?;
1379            clients.push(EngineServiceClient::with_interceptor(
1380                channel,
1381                interceptor.clone(),
1382            ));
1383        }
1384
1385        debug!(
1386            "Engine client pool connected ({} connections)",
1387            ENGINE_POOL_SIZE
1388        );
1389        Ok(Self {
1390            clients,
1391            next: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1392            identity_authorization: authoritative_worker_id
1393                .as_ref()
1394                .map(|_| interceptor.token.clone()),
1395            authoritative_worker_id,
1396            endpoint,
1397        })
1398    }
1399
1400    /// Get the next client from the pool (round-robin).
1401    async fn next_client(&mut self) -> Result<&mut EngineServiceClient<AuthenticatedChannel>> {
1402        // Bindings can retain an engine client across worker reconnections.
1403        // A renewed certificate needs a new TLS pool; never reuse the old
1404        // channel or attach its successor's token to the old certificate.
1405        if self
1406            .identity_authorization
1407            .as_ref()
1408            .is_some_and(|token| token.read().unwrap_or_else(|p| p.into_inner()).is_none())
1409        {
1410            *self = Self::connect(&self.endpoint).await?;
1411        }
1412        let idx = self.next.fetch_add(1, std::sync::atomic::Ordering::Relaxed) % self.clients.len();
1413        Ok(&mut self.clients[idx])
1414    }
1415
1416    /// Admit one logical activation and return the journal-authoritative decision.
1417    pub async fn begin_activation(
1418        &mut self,
1419        request: BeginActivationRequest,
1420    ) -> Result<BeginActivationResponse> {
1421        let mut timer = crate::core_metrics::RpcTimer::new(&request.run_id, "begin");
1422        for attempt in 0..ENGINE_ACTIVATION_RPC_ATTEMPTS {
1423            match self
1424                .next_client()
1425                .await?
1426                .begin_activation(request.clone())
1427                .await
1428            {
1429                Ok(response) => {
1430                    let response = response.into_inner();
1431                    timer.outcome(match response.outcome {
1432                        1 => "execute",
1433                        2 => "replay",
1434                        3 => "wait",
1435                        4 => "conflict",
1436                        5 => "cancelled",
1437                        _ => "unknown",
1438                    });
1439                    return Ok(response);
1440                }
1441                Err(status) if should_retry_activation_status(&status, attempt) => {
1442                    debug!(
1443                        attempt = attempt + 1,
1444                        status = %status,
1445                        "BeginActivation hit a transient gRPC status; retrying the exact command"
1446                    );
1447                    sleep_engine_retry(attempt).await;
1448                }
1449                Err(status) => {
1450                    timer.outcome("error");
1451                    return Err(activation_status("BeginActivation", status));
1452                }
1453            }
1454        }
1455        unreachable!("activation retry loop always returns")
1456    }
1457
1458    /// Commit one fenced activation completion and wait for its durability acknowledgement.
1459    pub async fn complete_activation(
1460        &mut self,
1461        request: CompleteActivationRequest,
1462    ) -> Result<CompleteActivationResponse> {
1463        let mut timer = crate::core_metrics::RpcTimer::new(&request.run_id, "complete");
1464        for attempt in 0..ENGINE_ACTIVATION_RPC_ATTEMPTS {
1465            match self
1466                .next_client()
1467                .await?
1468                .complete_activation(request.clone())
1469                .await
1470            {
1471                Ok(response) => {
1472                    let response = response.into_inner();
1473                    timer.outcome(if response.accepted {
1474                        if response.replayed {
1475                            "replay"
1476                        } else {
1477                            "success"
1478                        }
1479                    } else {
1480                        "unknown"
1481                    });
1482                    return Ok(response);
1483                }
1484                Err(status) if should_retry_activation_status(&status, attempt) => {
1485                    debug!(
1486                        attempt = attempt + 1,
1487                        status = %status,
1488                        "CompleteActivation hit a transient gRPC status; retrying the exact command"
1489                    );
1490                    sleep_engine_retry(attempt).await;
1491                }
1492                Err(status) => {
1493                    timer.outcome("error");
1494                    return Err(activation_status("CompleteActivation", status));
1495                }
1496            }
1497        }
1498        unreachable!("activation retry loop always returns")
1499    }
1500
1501    /// Commit one fenced activation failure and wait for its durability acknowledgement.
1502    pub async fn fail_activation(
1503        &mut self,
1504        request: FailActivationRequest,
1505    ) -> Result<FailActivationResponse> {
1506        let mut timer = crate::core_metrics::RpcTimer::new(&request.run_id, "fail");
1507        for attempt in 0..ENGINE_ACTIVATION_RPC_ATTEMPTS {
1508            match self
1509                .next_client()
1510                .await?
1511                .fail_activation(request.clone())
1512                .await
1513            {
1514                Ok(response) => {
1515                    let response = response.into_inner();
1516                    timer.outcome(if response.accepted {
1517                        "success"
1518                    } else {
1519                        "unknown"
1520                    });
1521                    return Ok(response);
1522                }
1523                Err(status) if should_retry_activation_status(&status, attempt) => {
1524                    debug!(
1525                        attempt = attempt + 1,
1526                        status = %status,
1527                        "FailActivation hit a transient gRPC status; retrying the exact command"
1528                    );
1529                    sleep_engine_retry(attempt).await;
1530                }
1531                Err(status) => {
1532                    timer.outcome("error");
1533                    return Err(activation_status("FailActivation", status));
1534                }
1535            }
1536        }
1537        unreachable!("activation retry loop always returns")
1538    }
1539
1540    /// Atomically park one fenced activation and its parent run until a
1541    /// durable timer generation is ready.
1542    pub async fn suspend_activation(
1543        &mut self,
1544        request: SuspendActivationRequest,
1545    ) -> Result<SuspendActivationResponse> {
1546        for attempt in 0..ENGINE_ACTIVATION_RPC_ATTEMPTS {
1547            match self
1548                .next_client()
1549                .await?
1550                .suspend_activation(request.clone())
1551                .await
1552            {
1553                Ok(response) => return Ok(response.into_inner()),
1554                Err(status) if should_retry_activation_status(&status, attempt) => {
1555                    debug!(
1556                        attempt = attempt + 1,
1557                        status = %status,
1558                        "SuspendActivation hit a transient gRPC status; retrying the exact command"
1559                    );
1560                    sleep_engine_retry(attempt).await;
1561                }
1562                Err(status) => return Err(activation_status("SuspendActivation", status)),
1563            }
1564        }
1565        unreachable!("activation retry loop always returns")
1566    }
1567
1568    /// Append a single record to the engine.
1569    pub async fn append(&mut self, record: Record) -> Result<(u64, i64)> {
1570        for attempt in 0..ENGINE_RPC_RETRY_ATTEMPTS {
1571            match self
1572                .next_client()
1573                .await?
1574                .append(AppendRequest {
1575                    record: Some(record.clone()),
1576                })
1577                .await
1578            {
1579                Ok(response) => {
1580                    let response = response.into_inner();
1581                    return Ok((response.offset, response.timestamp_ns));
1582                }
1583                Err(status)
1584                    if attempt + 1 < ENGINE_RPC_RETRY_ATTEMPTS
1585                        && is_retryable_engine_status(&status) =>
1586                {
1587                    debug!(
1588                        attempt = attempt + 1,
1589                        max = ENGINE_RPC_RETRY_ATTEMPTS,
1590                        "Engine Append hit retryable gRPC status: {}",
1591                        status
1592                    );
1593                    sleep_engine_retry(attempt).await;
1594                }
1595                Err(status) => {
1596                    debug!("Engine Append failed: {}", status);
1597                    return Err(engine_append_status_error("Engine Append", status));
1598                }
1599            }
1600        }
1601
1602        unreachable!("engine append retry loop always returns")
1603    }
1604
1605    /// Append a batch of records to the engine.
1606    pub async fn append_batch(&mut self, records: Vec<Record>) -> Result<i32> {
1607        validate_append_batch_records(&records)?;
1608        let expected = records.len();
1609        for attempt in 0..ENGINE_RPC_RETRY_ATTEMPTS {
1610            match self
1611                .next_client()
1612                .await?
1613                .append_batch(AppendBatchRequest {
1614                    records: records.clone(),
1615                })
1616                .await
1617            {
1618                Ok(response) => {
1619                    let response = response.into_inner();
1620                    return validate_append_batch_response(&response, expected);
1621                }
1622                Err(status)
1623                    if attempt + 1 < ENGINE_RPC_RETRY_ATTEMPTS
1624                        && is_retryable_engine_status(&status) =>
1625                {
1626                    debug!(
1627                        attempt = attempt + 1,
1628                        max = ENGINE_RPC_RETRY_ATTEMPTS,
1629                        "Engine AppendBatch hit retryable gRPC status: {}",
1630                        status
1631                    );
1632                    sleep_engine_retry(attempt).await;
1633                }
1634                Err(status) => {
1635                    debug!("Engine AppendBatch failed: {}", status);
1636                    return Err(engine_append_status_error("Engine AppendBatch", status));
1637                }
1638            }
1639        }
1640
1641        unreachable!("engine append batch retry loop always returns")
1642    }
1643
1644    /// Publish a bounded batch of ephemeral events and wait until the runtime
1645    /// acknowledges every frame. Closing each batch supplies the ordering
1646    /// barrier needed before a durable terminal event is appended.
1647    pub async fn stream_events(&mut self, mut events: Vec<EventStreamMessage>) -> Result<i64> {
1648        if events.is_empty() {
1649            return Ok(0);
1650        }
1651        if let Some(worker_id) = &self.authoritative_worker_id {
1652            for event in &mut events {
1653                event.worker_id.clone_from(worker_id);
1654            }
1655        }
1656        let expected = events.len() as i64;
1657        let response = self
1658            .next_client()
1659            .await?
1660            .event_stream(tokio_stream::iter(events))
1661            .await
1662            .map_err(|status| SdkError::Connection {
1663                message: format!("Engine EventStream failed: {status}"),
1664                code: crate::error::ErrorCode::ConnectionFailed,
1665                source: None,
1666            })?
1667            .into_inner();
1668        if !response.success || response.events_received != expected {
1669            return Err(SdkError::Connection {
1670                message: format!(
1671                    "Engine EventStream acknowledged {} events, want {}",
1672                    response.events_received, expected
1673                ),
1674                code: crate::error::ErrorCode::ConnectionFailed,
1675                source: None,
1676            });
1677        }
1678        Ok(response.events_received)
1679    }
1680}
1681
1682/// Build an engine `Record` from SDK event fields.
1683pub fn build_engine_record(
1684    tenant_id: String,
1685    run_id: String,
1686    event_type: String,
1687    data: Vec<u8>,
1688    timestamp_ns: i64,
1689    step_key: String,
1690    correlation_id: String,
1691    parent_event_id: String,
1692    metadata: HashMap<String, String>,
1693) -> Record {
1694    Record {
1695        offset: 0, // Assigned by engine
1696        project_id: tenant_id,
1697        run_id,
1698        event_type,
1699        data,
1700        timestamp_ns,
1701        step_key,
1702        correlation_id,
1703        parent_event_id,
1704        metadata,
1705        data_type: "json".to_string(),
1706        data_checksum: vec![],
1707        data_compressed: false,
1708    }
1709}
1710
1711#[cfg(test)]
1712mod tests {
1713    use super::*;
1714
1715    #[tokio::test]
1716    async fn identity_rotation_wakes_connection_loop_only_after_invalidation() {
1717        let token = Arc::new(RwLock::new(Some(bearer_metadata("first").unwrap())));
1718        assert!(
1719            tokio::time::timeout(Duration::from_millis(10), wait_for_identity_rotation(None))
1720                .await
1721                .is_err()
1722        );
1723        assert!(tokio::time::timeout(
1724            Duration::from_millis(10),
1725            wait_for_identity_rotation(Some(&token))
1726        )
1727        .await
1728        .is_err());
1729        // Refresh on the same certificate must not restart a worker.
1730        *token.write().unwrap() = Some(bearer_metadata("refreshed").unwrap());
1731        assert!(tokio::time::timeout(
1732            Duration::from_millis(10),
1733            wait_for_identity_rotation(Some(&token))
1734        )
1735        .await
1736        .is_err());
1737        let rotated = wait_for_identity_rotation(Some(&token));
1738        tokio::pin!(rotated);
1739        assert!(
1740            tokio::time::timeout(Duration::from_millis(10), &mut rotated)
1741                .await
1742                .is_err()
1743        );
1744        *token.write().unwrap() = None;
1745        tokio::time::timeout(Duration::from_secs(1), rotated)
1746            .await
1747            .unwrap();
1748    }
1749
1750    #[tokio::test]
1751    async fn https_channel_enables_tls_without_an_explicit_client_config() {
1752        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1753        let address = listener.local_addr().unwrap();
1754        let accept = tokio::spawn(async move {
1755            let (socket, _) = listener.accept().await.unwrap();
1756            drop(socket);
1757        });
1758
1759        let endpoint = Channel::from_shared(format!("https://{address}"))
1760            .expect("test endpoint should be valid");
1761        let error = configure_channel_tls(endpoint, None)
1762            .expect("default HTTPS TLS configuration should be valid")
1763            .connect_timeout(Duration::from_secs(1))
1764            .connect()
1765            .await
1766            .expect_err("the test listener intentionally does not speak TLS");
1767
1768        accept.await.unwrap();
1769        let error = format!("{error:?}");
1770        assert!(
1771            !error.contains("HttpsUriWithoutTlsSupport"),
1772            "HTTPS channel attempted to connect without TLS: {error}"
1773        );
1774    }
1775
1776    #[test]
1777    fn external_worker_token_request_contains_only_immutable_authority() {
1778        let authority = ExternalWorkerAuthority {
1779            project_id: "project-1".into(),
1780            environment_id: "environment-1".into(),
1781            deployment_id: "deployment-1".into(),
1782            worker_pool_id: "pool-1".into(),
1783            runtime_endpoint: "https://runtime.example".into(),
1784            protocol: "pull.v1".into(),
1785            auth_profile: AUTH_PROFILE_TOKEN_AUTH.into(),
1786            identity_endpoint: String::new(),
1787        };
1788
1789        let value = serde_json::to_value(ExternalWorkerTokenRequest::from(&authority)).unwrap();
1790
1791        assert_eq!(
1792            value,
1793            serde_json::json!({
1794                "project_id": "project-1",
1795                "environment_id": "environment-1",
1796                "deployment_id": "deployment-1",
1797                "worker_pool_id": "pool-1"
1798            })
1799        );
1800    }
1801
1802    #[test]
1803    fn external_worker_authority_overrides_preconfigured_routing_metadata() {
1804        let authority = ExternalWorkerAuthority {
1805            project_id: "discovered-project".into(),
1806            environment_id: "discovered-environment".into(),
1807            deployment_id: "discovered-deployment".into(),
1808            worker_pool_id: "discovered-pool".into(),
1809            runtime_endpoint: "https://runtime.example".into(),
1810            protocol: "pull.v1".into(),
1811            auth_profile: AUTH_PROFILE_TOKEN_AUTH.into(),
1812            identity_endpoint: String::new(),
1813        };
1814        let mut metadata = HashMap::from([
1815            ("project_id".to_string(), "configured-project".to_string()),
1816            (
1817                "deployment_id".to_string(),
1818                "configured-deployment".to_string(),
1819            ),
1820        ]);
1821
1822        authority.apply_to_metadata(&mut metadata);
1823
1824        assert_eq!(metadata.get("project_id").unwrap(), "discovered-project");
1825        assert_eq!(
1826            metadata.get("environment_id").unwrap(),
1827            "discovered-environment"
1828        );
1829        assert_eq!(
1830            metadata.get("deployment_id").unwrap(),
1831            "discovered-deployment"
1832        );
1833        assert_eq!(metadata.get("worker_pool_id").unwrap(), "discovered-pool");
1834    }
1835
1836    #[test]
1837    fn retryable_engine_status_includes_partition_handoff_errors() {
1838        let status = tonic::Status::internal(
1839            "Engine AppendBatch failed: no sequencer available after retry",
1840        );
1841        assert!(is_retryable_engine_status(&status));
1842
1843        let status = tonic::Status::failed_precondition("stale epoch on forward: 162 < 163");
1844        assert!(is_retryable_engine_status(&status));
1845    }
1846
1847    #[test]
1848    fn retryable_engine_status_does_not_retry_plain_internal_errors() {
1849        let status = tonic::Status::internal("serialization failed");
1850        assert!(!is_retryable_engine_status(&status));
1851    }
1852
1853    #[test]
1854    fn activation_rpc_retry_is_bounded_to_transient_statuses() {
1855        let lagging_replica =
1856            tonic::Status::unavailable("activation actv1_test is not yet visible on this replica");
1857        assert!(should_retry_activation_status(&lagging_replica, 0));
1858        assert!(!should_retry_activation_status(
1859            &lagging_replica,
1860            ENGINE_ACTIVATION_RPC_ATTEMPTS - 1
1861        ));
1862
1863        let conflict = tonic::Status::already_exists("payload conflict");
1864        assert!(!should_retry_activation_status(&conflict, 0));
1865
1866        let typed_conflict = tonic::Status::with_details(
1867            tonic::Code::Unavailable,
1868            "typed activation outcome",
1869            bytes::Bytes::from_static(b"activation-error-detail"),
1870        );
1871        assert!(!should_retry_activation_status(&typed_conflict, 0));
1872    }
1873
1874    #[tokio::test]
1875    async fn retired_engine_identity_reconnects_before_using_cached_channels() {
1876        let mut client = EngineClient {
1877            // If stale clients are selected, the empty pool makes this fail.
1878            clients: Vec::new(),
1879            next: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1880            authoritative_worker_id: Some("worker".into()),
1881            identity_authorization: Some(Arc::new(RwLock::new(None))),
1882            endpoint: "invalid endpoint".into(),
1883        };
1884        let error = client.next_client().await.unwrap_err();
1885        assert!(error.to_string().contains("Invalid engine endpoint"));
1886        assert_eq!(client.next.load(std::sync::atomic::Ordering::Relaxed), 0);
1887    }
1888
1889    #[tokio::test]
1890    async fn append_batch_rejects_multi_run_before_retry_or_rpc() {
1891        let mut client = EngineClient {
1892            clients: Vec::new(),
1893            next: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1894            authoritative_worker_id: None,
1895            identity_authorization: None,
1896            endpoint: "http://127.0.0.1:1".into(),
1897        };
1898        let records = vec![
1899            Record {
1900                run_id: "run-a".into(),
1901                event_type: "step.started".into(),
1902                ..Default::default()
1903            },
1904            Record {
1905                run_id: "run-b".into(),
1906                event_type: "step.completed".into(),
1907                ..Default::default()
1908            },
1909        ];
1910
1911        let error = client.append_batch(records).await.unwrap_err();
1912
1913        assert!(matches!(error, SdkError::InvalidArgument { .. }));
1914        assert_eq!(
1915            client.next.load(std::sync::atomic::Ordering::Relaxed),
1916            0,
1917            "preflight rejection must happen before the retry loop selects a connection"
1918        );
1919    }
1920
1921    #[test]
1922    fn append_batch_rejects_malformed_response_counts() {
1923        for response in [
1924            AppendBatchResponse {
1925                offsets: vec![1],
1926                written_count: 1,
1927            },
1928            AppendBatchResponse {
1929                offsets: vec![1, 2],
1930                written_count: -1,
1931            },
1932            AppendBatchResponse {
1933                offsets: vec![1, 2],
1934                written_count: 3,
1935            },
1936        ] {
1937            let error = validate_append_batch_response(&response, 2).unwrap_err();
1938            assert!(matches!(error, SdkError::Internal(_)));
1939        }
1940    }
1941
1942    #[test]
1943    fn protocol_capability_negotiation_fails_closed_when_required() {
1944        let durable = DURABLE_ACTIVATION_V1_CAPABILITY.to_string();
1945        let error = validate_protocol_capabilities(
1946            std::slice::from_ref(&durable),
1947            std::slice::from_ref(&durable),
1948            &[],
1949            &[],
1950        )
1951        .unwrap_err();
1952        assert_eq!(error.code(), crate::error::ErrorCode::DurabilityUnavailable);
1953
1954        let error =
1955            validate_protocol_capabilities(&[], &[], &[], &["runtime_v2".into()]).unwrap_err();
1956        assert_eq!(error.code(), crate::error::ErrorCode::DurabilityUnavailable);
1957
1958        validate_protocol_capabilities(
1959            std::slice::from_ref(&durable),
1960            std::slice::from_ref(&durable),
1961            std::slice::from_ref(&durable),
1962            &[],
1963        )
1964        .unwrap();
1965    }
1966
1967    #[test]
1968    fn protocol_capability_negotiation_retains_only_the_intersection() {
1969        assert_eq!(
1970            negotiated_protocol_capabilities(
1971                &[
1972                    DURABLE_ACTIVATION_V1_CAPABILITY.into(),
1973                    "worker_only".into(),
1974                    DURABLE_ACTIVATION_V1_CAPABILITY.into(),
1975                ],
1976                &[
1977                    DURABLE_ACTIVATION_V1_CAPABILITY.into(),
1978                    "runtime_only".into(),
1979                ],
1980            ),
1981            vec![DURABLE_ACTIVATION_V1_CAPABILITY.to_string()]
1982        );
1983    }
1984
1985    #[test]
1986    fn server_slot_scaling_is_optional_and_additive() {
1987        let (supported, required) = worker_protocol_capabilities();
1988        assert!(supported.contains(&SERVER_SLOT_SCALING_V1_CAPABILITY.to_string()));
1989        assert!(!required.contains(&SERVER_SLOT_SCALING_V1_CAPABILITY.to_string()));
1990
1991        #[derive(Clone, PartialEq, prost::Message)]
1992        struct LegacyPollJobResponse {
1993            #[prost(message, optional, tag = "1")]
1994            job: Option<crate::pb::JobAssignment>,
1995        }
1996
1997        let legacy_bytes = prost::Message::encode_to_vec(&LegacyPollJobResponse { job: None });
1998        let decoded_new = <PollJobResponse as prost::Message>::decode(legacy_bytes.as_slice())
1999            .expect("new worker must decode an old response");
2000        assert!(decoded_new.slot_scaling.is_none());
2001
2002        let new_bytes = prost::Message::encode_to_vec(&PollJobResponse {
2003            job: None,
2004            slot_scaling: Some(crate::pb::SlotScalingHint {
2005                decision: crate::pb::SlotScalingDecision::ScaleUp as i32,
2006                suggested_delta: 2,
2007            }),
2008        });
2009        let decoded_legacy =
2010            <LegacyPollJobResponse as prost::Message>::decode(new_bytes.as_slice())
2011                .expect("old worker must ignore a new response field");
2012        assert!(decoded_legacy.job.is_none());
2013    }
2014}
2015
2016/// Result of a checkpoint operation
2017#[derive(Debug, Clone)]
2018pub struct CheckpointResult {
2019    /// Whether the checkpoint was processed successfully
2020    pub success: bool,
2021    /// Error message if the checkpoint failed
2022    pub error_message: Option<String>,
2023    /// Whether the step was already memoized (for STEP_STARTED checkpoints)
2024    pub memoized: bool,
2025    /// Cached output if memoized
2026    pub cached_output: Option<Vec<u8>>,
2027}