Skip to main content

studio_worker/ws/
session.rs

1//! Long-running WebSocket session that owns the worker's lifecycle.
2//!
3//! Replaces the four polling loops (`spawn_heartbeat`, `spawn_claim_loop`,
4//! `spawn_log_shipper`, plus the implicit completion path) with a single
5//! `spawn_ws_session` coordinator + a small handful of helper tasks that
6//! all push frames through a shared `WsSender`.
7//!
8//! Reconnect policy: on a transport error or non-auth close, back off
9//! `BASE_BACKOFF_MS * 2^attempt` and try again, up to
10//! `cfg.ws_reconnect_attempts`.  Out of retries → return `Err` and the
11//! systemd / launchd unit restarts the binary.
12use std::sync::{
13    atomic::{AtomicBool, Ordering},
14    Arc,
15};
16use std::time::Duration;
17
18use anyhow::{anyhow, Result};
19use parking_lot::Mutex;
20use tokio::sync::mpsc;
21use tracing::{info, warn, Instrument as _};
22
23use crate::config::SharedConfig;
24use crate::engine::Engine;
25use crate::http::ApiClient;
26use crate::job_run::JobRun;
27use crate::runtime::{
28    is_unsupported_kind, prompt_for, push_log_with_observers, set_session_state, truncate_prompt,
29    wait_with_stop, CurrentJob, JobOutcome, JobSource, SessionState, WorkerObservers,
30};
31use crate::types::{LogEntry, TaskResult};
32use crate::ws::client::{connect, WsClientError, WsResult, WsSender};
33use crate::ws::types::{HelloFrame, JobOfferClaim, WorkerInbound, WorkerOutbound};
34
35/// Tracing target used for every event emitted by the session.
36const TRACE_TARGET: &str = "studio_worker::ws::session";
37
38const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
39const LOG_FLUSH_INTERVAL: Duration = Duration::from_secs(1);
40const SHUTDOWN_TICK: Duration = Duration::from_millis(250);
41const BASE_BACKOFF_MS: u64 = 1_000;
42const MAX_BACKOFF_MS: u64 = 30_000;
43/// Reconnect attempts before giving up, when the operator hasn't
44/// pinned `ws_reconnect_attempts`.  `0` = retry forever.
45///
46/// A zero-touch worker must never permanently give up while
47/// unattended: the turnkey install runs the UI (or an autostart tray)
48/// with **no** service manager to restart a process that exits, so a
49/// laptop that sleeps through a wifi outage used to wake as a dead
50/// worker after 5 failed reconnects.  Infinite-with-capped-backoff is
51/// the only default that keeps "approve once, never touch again" true.
52/// Operators who want fail-fast under systemd can still set a finite
53/// `ws_reconnect_attempts`.
54const DEFAULT_RECONNECT_ATTEMPTS: u32 = 0;
55/// Extra attempts for the multipart result upload when the studio
56/// returns a 5xx / transport error.  A blip is far cheaper to retry
57/// than the full GPU regeneration a reported `Fail` causes.
58const UPLOAD_RETRIES: u32 = 2;
59/// Base pause between upload retries (grows linearly per attempt).
60const UPLOAD_RETRY_PAUSE: Duration = Duration::from_secs(1);
61/// If no frame (not even a `heartbeatAck`) arrives from the studio within this window, treat the
62/// connection as dead and tear the session down. The studio acks every heartbeat (~5s), so a live
63/// connection always yields a frame well inside this budget; the only time it elapses is a
64/// half-open / dead-peer socket where the reader would otherwise block on `source.next()` forever.
65const READ_IDLE_TIMEOUT: Duration = Duration::from_secs(20);
66
67/// Outcome of a single session attempt.  The reconnect loop decides
68/// whether to back off + retry based on the variant.
69#[derive(Debug)]
70pub enum SessionOutcome {
71    /// Caller requested shutdown; do not reconnect.
72    Stopped,
73    /// Lost the connection unexpectedly; reconnect after backoff.
74    Disconnected,
75    /// Server rejected auth; do not reconnect.
76    AuthFailed(String),
77    /// Server sent a fatal error frame; do not reconnect.
78    Fatal(String),
79}
80
81/// Tunables for the session loop — dialed down in tests.
82#[derive(Debug, Clone, Copy)]
83pub struct SessionSchedule {
84    pub heartbeat: Duration,
85    pub log_flush: Duration,
86    pub shutdown_tick: Duration,
87    pub base_backoff_ms: u64,
88    pub max_backoff_ms: u64,
89    /// Reader gives up + reports a disconnect if no server frame arrives within this window.
90    pub read_idle_timeout: Duration,
91}
92
93impl Default for SessionSchedule {
94    fn default() -> Self {
95        Self {
96            heartbeat: HEARTBEAT_INTERVAL,
97            log_flush: LOG_FLUSH_INTERVAL,
98            shutdown_tick: SHUTDOWN_TICK,
99            base_backoff_ms: BASE_BACKOFF_MS,
100            max_backoff_ms: MAX_BACKOFF_MS,
101            read_idle_timeout: READ_IDLE_TIMEOUT,
102        }
103    }
104}
105
106impl SessionSchedule {
107    pub fn fast_for_tests() -> Self {
108        Self {
109            heartbeat: Duration::from_millis(5),
110            log_flush: Duration::from_millis(5),
111            shutdown_tick: Duration::from_millis(5),
112            base_backoff_ms: 1,
113            max_backoff_ms: 10,
114            // Generous vs the 5ms heartbeat so the existing fast tests never trip it; the
115            // silent-connection test overrides this with a tiny value to exercise the timeout.
116            read_idle_timeout: Duration::from_secs(5),
117        }
118    }
119}
120
121/// Top-level driver: connect, run a session, reconnect on disconnect,
122/// give up after `cfg.ws_reconnect_attempts` failures.
123///
124/// `paused` is a runtime-only flag (not persisted to `Config`).  When
125/// true, the heartbeat reports `autoEnabled = false` and incoming
126/// offers are rejected, so the studio stops sending new jobs.  In-
127/// flight work is allowed to finish.
128#[cfg_attr(coverage_nightly, coverage(off))]
129pub async fn spawn_ws_session(
130    cfg: SharedConfig,
131    stop: Arc<AtomicBool>,
132    logs: Arc<Mutex<Vec<LogEntry>>>,
133    busy: Arc<AtomicBool>,
134    paused: Arc<AtomicBool>,
135    observers: WorkerObservers,
136    schedule: SessionSchedule,
137) -> Result<()> {
138    let max_attempts = {
139        let guard = cfg.lock();
140        guard
141            .ws_reconnect_attempts
142            .unwrap_or(DEFAULT_RECONNECT_ATTEMPTS)
143    };
144
145    let mut attempt: u32 = 0;
146    let mut waiting_for_creds_logged = false;
147    loop {
148        if stop.load(Ordering::SeqCst) {
149            return Ok(());
150        }
151        // Credentials may not exist yet (first launch — the
152        // auto-register loop is racing to populate them).  Poll the
153        // shared config until both `worker_id` and `auth_token` show
154        // up, instead of failing the whole session loop.  This is
155        // what lets the UI's parallel auto-register + WS flow work.
156        if !has_credentials(&cfg) {
157            if !waiting_for_creds_logged {
158                set_session_state(&observers, SessionState::WaitingForApproval);
159                push_log_with_observers(
160                    &logs,
161                    Some(&observers),
162                    "info",
163                    "ws",
164                    "waiting for operator approval before opening the session",
165                    None,
166                );
167                waiting_for_creds_logged = true;
168            }
169            wait_with_stop(Duration::from_secs(1), &stop, schedule.shutdown_tick).await;
170            continue;
171        }
172        waiting_for_creds_logged = false;
173
174        set_session_state(&observers, SessionState::Connecting);
175        let welcomed = AtomicBool::new(false);
176        match run_one_session(
177            &cfg, &stop, &logs, &busy, &paused, &observers, schedule, &welcomed,
178        )
179        .await
180        {
181            Ok(SessionOutcome::Stopped) => {
182                set_session_state(&observers, SessionState::Stopped);
183                return Ok(());
184            }
185            Ok(SessionOutcome::AuthFailed(reason)) => {
186                set_session_state(
187                    &observers,
188                    SessionState::AuthFailed {
189                        reason: reason.clone(),
190                    },
191                );
192                push_log_with_observers(
193                    &logs,
194                    Some(&observers),
195                    "error",
196                    "ws",
197                    &format!("auth failed: {reason}. Re-register the worker."),
198                    None,
199                );
200                return Err(anyhow!("ws auth failed: {reason}"));
201            }
202            Ok(SessionOutcome::Fatal(reason)) => {
203                set_session_state(
204                    &observers,
205                    SessionState::Fatal {
206                        reason: reason.clone(),
207                    },
208                );
209                push_log_with_observers(
210                    &logs,
211                    Some(&observers),
212                    "error",
213                    "ws",
214                    &format!("fatal: {reason}"),
215                    None,
216                );
217                return Err(anyhow!("ws fatal: {reason}"));
218            }
219            outcome @ (Ok(SessionOutcome::Disconnected) | Err(_)) => {
220                // A session that successfully connected shouldn't count its later drop toward the
221                // connect-failure cap — only consecutive failures to connect should accumulate, so
222                // a long-lived worker isn't killed by transient mid-session disconnects.
223                if welcomed.load(Ordering::SeqCst) {
224                    attempt = 0;
225                }
226                attempt += 1;
227                if reconnect_exhausted(max_attempts, attempt) {
228                    set_session_state(
229                        &observers,
230                        SessionState::Fatal {
231                            reason: format!("gave up after {attempt} reconnect attempts"),
232                        },
233                    );
234                    push_log_with_observers(
235                        &logs,
236                        Some(&observers),
237                        "error",
238                        "ws",
239                        &format!("giving up after {attempt} reconnect attempts"),
240                        None,
241                    );
242                    return Err(anyhow!("ws reconnect cap reached"));
243                }
244                set_session_state(&observers, SessionState::Reconnecting { attempt });
245                let backoff = backoff_for(attempt, schedule);
246                push_log_with_observers(
247                    &logs,
248                    Some(&observers),
249                    "warn",
250                    "ws",
251                    &reconnect_breadcrumb(outcome.as_ref().err(), attempt, backoff),
252                    None,
253                );
254                wait_with_stop(backoff, &stop, schedule.shutdown_tick).await;
255            }
256        }
257    }
258}
259
260/// Outcome of waiting for the server's Welcome (or an error) right
261/// after sending Hello.  Drives the precondition gate that keeps the
262/// heartbeat / log-shipper pumps from racing the studio's async auth
263/// flow.
264enum WelcomeOutcome {
265    Welcomed,
266    AuthFailed(String),
267    Fatal(String),
268    Disconnected,
269}
270
271/// Pull events from the reader until we see a Welcome (success) or an
272/// Error / Disconnect (failure).  Any acks / offers that arrive
273/// before the Welcome are pushed into the logs and discarded — the
274/// studio shouldn't be sending them at this stage, but if it does,
275/// the dispatch loop will pick the next ones up.
276#[cfg_attr(coverage_nightly, coverage(off))]
277async fn wait_for_welcome(
278    event_rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
279    logs: &Arc<Mutex<Vec<LogEntry>>>,
280    observers: &WorkerObservers,
281) -> WelcomeOutcome {
282    while let Some(event) = event_rx.recv().await {
283        match event {
284            SessionEvent::Frame(WorkerOutbound::Welcome {
285                worker_id: wid,
286                server_time,
287            }) => {
288                push_log_with_observers(
289                    logs,
290                    Some(observers),
291                    "info",
292                    "ws",
293                    &welcome_breadcrumb(&wid, &server_time),
294                    None,
295                );
296                return WelcomeOutcome::Welcomed;
297            }
298            SessionEvent::Frame(WorkerOutbound::Error { code, message }) => {
299                push_log_with_observers(
300                    logs,
301                    Some(observers),
302                    "error",
303                    "ws",
304                    &format!("server error before welcome {code:?}: {message}"),
305                    None,
306                );
307                return match code {
308                    crate::ws::types::WorkerErrorCode::AuthFailed => {
309                        WelcomeOutcome::AuthFailed(message)
310                    }
311                    _ => WelcomeOutcome::Fatal(message),
312                };
313            }
314            SessionEvent::Frame(other) => {
315                push_log_with_observers(
316                    logs,
317                    Some(observers),
318                    "warn",
319                    "ws",
320                    &format!("server sent unexpected frame before welcome: {other:?}"),
321                    None,
322                );
323                // Keep waiting — maybe the next frame is Welcome.
324            }
325            SessionEvent::Disconnected(WsClientError::AuthFailed { reason }) => {
326                return WelcomeOutcome::AuthFailed(reason);
327            }
328            SessionEvent::Disconnected(_) => return WelcomeOutcome::Disconnected,
329            SessionEvent::Stopped => return WelcomeOutcome::Disconnected,
330        }
331    }
332    WelcomeOutcome::Disconnected
333}
334
335/// True iff the shared config has both `worker_id` and `auth_token`
336/// populated.  The auto-register flow writes them through on
337/// approval.
338fn has_credentials(cfg: &SharedConfig) -> bool {
339    let guard = cfg.lock();
340    guard
341        .worker_id
342        .as_deref()
343        .map(|s| !s.is_empty())
344        .unwrap_or(false)
345        && guard
346            .auth_token
347            .as_deref()
348            .map(|s| !s.is_empty())
349            .unwrap_or(false)
350}
351
352/// One end-to-end session attempt: connect, hello, run until shutdown
353/// or disconnect.
354#[cfg_attr(coverage_nightly, coverage(off))]
355// Eight collaborators (config + shared flags + observers + schedule + welcomed signal);
356// grouping them adds indirection without improving readability.
357#[allow(clippy::too_many_arguments)]
358async fn run_one_session(
359    cfg: &SharedConfig,
360    stop: &Arc<AtomicBool>,
361    logs: &Arc<Mutex<Vec<LogEntry>>>,
362    busy: &Arc<AtomicBool>,
363    paused: &Arc<AtomicBool>,
364    observers: &WorkerObservers,
365    schedule: SessionSchedule,
366    welcomed: &AtomicBool,
367) -> Result<SessionOutcome> {
368    let (api_base_url, worker_id, auth_token) = {
369        let guard = cfg.lock();
370        (
371            guard.api_base_url.clone(),
372            guard.worker_id.clone().unwrap_or_default(),
373            guard.auth_token.clone().unwrap_or_default(),
374        )
375    };
376    if worker_id.is_empty() || auth_token.is_empty() {
377        return Ok(SessionOutcome::Fatal(
378            "worker_id or auth_token missing; run register".to_string(),
379        ));
380    }
381
382    push_log_with_observers(
383        logs,
384        Some(observers),
385        "info",
386        "ws",
387        &format!("connecting to {api_base_url}"),
388        None,
389    );
390    let client = match connect(&api_base_url, &worker_id, &auth_token).await {
391        Ok(c) => c,
392        Err(WsClientError::AuthFailed { reason }) => {
393            return Ok(SessionOutcome::AuthFailed(reason));
394        }
395        Err(e) => {
396            push_log_with_observers(
397                logs,
398                Some(observers),
399                "warn",
400                "ws",
401                &format!("connect failed: {e}"),
402                None,
403            );
404            return Ok(SessionOutcome::Disconnected);
405        }
406    };
407    let (sender, receiver) = client.split();
408
409    // Send hello with the current capabilities.
410    let engine = crate::engine::build(&cfg.lock())?;
411    let capabilities = crate::runtime::build_capabilities_with(
412        &cfg.lock(),
413        &*engine,
414        !paused.load(Ordering::SeqCst),
415    );
416    // Record exactly what we're about to advertise so the worker's logs
417    // (and the studio's shipped-log view) show the offered kinds /
418    // models / VRAM budget — otherwise the handshake is opaque and
419    // "why won't it claim X jobs" can't be answered from the logs.
420    push_log_with_observers(
421        logs,
422        Some(observers),
423        "info",
424        "ws",
425        &crate::runtime::summarize_capabilities(&capabilities),
426        None,
427    );
428    // A threshold above the card's detected VRAM makes the studio offer
429    // jobs this GPU can't fit — they OOM on load.  Flag the
430    // misconfiguration on the handshake so the OOM has an operator-facing
431    // cause instead of surfacing only as a failed job.
432    if let Some(warning) = crate::runtime::vram_threshold_warning(&capabilities) {
433        push_log_with_observers(logs, Some(observers), "warn", "ws", &warning, None);
434    }
435    sender
436        .send(&WorkerInbound::Hello(HelloFrame {
437            auth_token: auth_token.clone(),
438            capabilities: capabilities.clone(),
439        }))
440        .await
441        .map_err(|e| anyhow!("hello send failed: {e}"))?;
442    info!(target: TRACE_TARGET, worker_id = %worker_id, "hello sent");
443
444    let (event_tx, event_rx) = mpsc::unbounded_channel::<SessionEvent>();
445
446    // Reader task: pump frames into the event channel.
447    let reader = spawn_reader(receiver, event_tx.clone(), schedule.read_idle_timeout);
448
449    // Wait for the server's `Welcome` (or an error) before starting
450    // the heartbeat / log-shipper pumps.  Without this gate, the
451    // first heartbeat fires immediately (tokio `interval()` returns
452    // at t=0) and races the studio's async Hello-auth flow: a
453    // heartbeat arriving while the session is still marked
454    // `authenticated: false` server-side gets rejected with
455    // `protocol_violation: session not authenticated`, killing the
456    // session.
457    let mut event_rx = event_rx;
458    match wait_for_welcome(&mut event_rx, logs, observers).await {
459        WelcomeOutcome::Welcomed => {
460            welcomed.store(true, Ordering::SeqCst);
461            set_session_state(observers, SessionState::Connected);
462        }
463        WelcomeOutcome::AuthFailed(reason) => {
464            let _ = sender.close(1000, "auth failed").await;
465            let _ = reader.await;
466            return Ok(SessionOutcome::AuthFailed(reason));
467        }
468        WelcomeOutcome::Fatal(reason) => {
469            let _ = sender.close(1000, "protocol violation").await;
470            let _ = reader.await;
471            return Ok(SessionOutcome::Fatal(reason));
472        }
473        WelcomeOutcome::Disconnected => {
474            let _ = reader.await;
475            return Ok(SessionOutcome::Disconnected);
476        }
477    }
478
479    // Heartbeat task.  Reuses the engine handle built for the Hello
480    // frame (rebuilding fires every engine's registration log every
481    // 5s and floods the logs) but rebuilds the capability snapshot
482    // from the live config each tick, so operator edits (e.g. a new
483    // VRAM threshold saved from the UI's Config page) reach the studio
484    // without waiting for a reconnect.
485    let engine_arc: Arc<dyn Engine> = engine.into();
486    let heartbeat = spawn_heartbeat_pump(
487        cfg.clone(),
488        engine_arc.clone(),
489        sender.clone(),
490        stop.clone(),
491        paused.clone(),
492        logs.clone(),
493        observers.clone(),
494        schedule,
495    );
496
497    // Log shipper task.
498    let log_shipper = spawn_log_shipper_pump(sender.clone(), logs.clone(), stop.clone(), schedule);
499
500    // Shutdown observer: ticks until stop flag is set, then drops the channel.
501    let shutdown_observer = spawn_shutdown_observer(stop.clone(), event_tx.clone(), schedule);
502    drop(event_tx);
503
504    let ctx = SessionContext {
505        sender: sender.clone(),
506        engine: engine_arc,
507        logs: logs.clone(),
508        busy: busy.clone(),
509        paused: paused.clone(),
510        observers: observers.clone(),
511        api_base_url: api_base_url.clone(),
512        worker_id: worker_id.clone(),
513        auth_token: auth_token.clone(),
514    };
515    let outcome = run_dispatch_loop(ctx, event_rx).await;
516
517    // The session is ending (disconnect or shutdown). The heartbeat / log-shipper /
518    // shutdown-observer pumps only break on the *global* stop flag or a send failure, so on a
519    // silent-but-open socket — where heartbeat sends still succeed into the TCP buffer — they would
520    // loop forever and block this function from returning, which is exactly the post-job reconnect
521    // hang. Abort them so teardown is bounded regardless of socket state, then best-effort close +
522    // drain the aborted handles (await returns promptly with Cancelled).
523    reader.abort();
524    heartbeat.abort();
525    log_shipper.abort();
526    shutdown_observer.abort();
527    let _ = sender.close(1000, "session ended").await;
528    let _ = reader.await;
529    let _ = heartbeat.await;
530    let _ = log_shipper.await;
531    let _ = shutdown_observer.await;
532    Ok(outcome)
533}
534
535/// All the events the dispatch loop reacts to.
536#[derive(Debug)]
537enum SessionEvent {
538    /// Frame arrived from the server.
539    Frame(WorkerOutbound),
540    /// Engine task finished (success or fail already reported).
541    Stopped,
542    /// Reader hit EOF / error.
543    Disconnected(WsClientError),
544}
545
546/// Bundle of immutable per-session settings the dispatcher passes
547/// around — keeps clippy's `too_many_arguments` lint happy.  Cloning
548/// is cheap: every field is an `Arc`, a cloneable sender, or a small
549/// `String`.
550#[derive(Clone)]
551struct SessionContext {
552    sender: WsSender,
553    engine: Arc<dyn Engine>,
554    logs: Arc<Mutex<Vec<LogEntry>>>,
555    busy: Arc<AtomicBool>,
556    paused: Arc<AtomicBool>,
557    observers: WorkerObservers,
558    api_base_url: String,
559    worker_id: String,
560    auth_token: String,
561}
562
563#[cfg_attr(coverage_nightly, coverage(off))]
564async fn run_dispatch_loop(
565    ctx: SessionContext,
566    mut event_rx: mpsc::UnboundedReceiver<SessionEvent>,
567) -> SessionOutcome {
568    while let Some(event) = event_rx.recv().await {
569        match event {
570            SessionEvent::Disconnected(WsClientError::AuthFailed { reason }) => {
571                return SessionOutcome::AuthFailed(reason);
572            }
573            SessionEvent::Disconnected(_) => return SessionOutcome::Disconnected,
574            SessionEvent::Stopped => return SessionOutcome::Stopped,
575            SessionEvent::Frame(frame) => match frame {
576                WorkerOutbound::Welcome {
577                    worker_id: wid,
578                    server_time,
579                } => {
580                    push_log_with_observers(
581                        &ctx.logs,
582                        Some(&ctx.observers),
583                        "info",
584                        "ws",
585                        &welcome_breadcrumb(&wid, &server_time),
586                        None,
587                    );
588                }
589                WorkerOutbound::Offer { claim } => {
590                    handle_offer(&ctx, *claim);
591                }
592                WorkerOutbound::Error { code, message } => {
593                    push_log_with_observers(
594                        &ctx.logs,
595                        Some(&ctx.observers),
596                        "error",
597                        "ws",
598                        &format!("server error {code:?}: {message}"),
599                        None,
600                    );
601                    return match code {
602                        crate::ws::types::WorkerErrorCode::AuthFailed => {
603                            SessionOutcome::AuthFailed(message)
604                        }
605                        _ => SessionOutcome::Fatal(message),
606                    };
607                }
608                WorkerOutbound::CompleteAck { job_id } => {
609                    push_log_with_observers(
610                        &ctx.logs,
611                        Some(&ctx.observers),
612                        "info",
613                        "ws",
614                        &result_ack_breadcrumb("completion", &job_id),
615                        Some(job_id),
616                    );
617                }
618                WorkerOutbound::FailAck { job_id } => {
619                    push_log_with_observers(
620                        &ctx.logs,
621                        Some(&ctx.observers),
622                        "info",
623                        "ws",
624                        &result_ack_breadcrumb("failure", &job_id),
625                        Some(job_id),
626                    );
627                }
628                WorkerOutbound::HeartbeatAck => {
629                    // Heartbeat acks fire every ~5s; logging each would
630                    // flood the operator log with no diagnostic value
631                    // (a genuinely missed ack already surfaces via the
632                    // read-idle timeout + reconnect breadcrumb).
633                }
634            },
635        }
636    }
637    SessionOutcome::Disconnected
638}
639
640#[cfg_attr(coverage_nightly, coverage(off))]
641fn handle_offer(ctx: &SessionContext, claim: JobOfferClaim) {
642    let job_id = claim.job_id.clone();
643    push_log_with_observers(
644        &ctx.logs,
645        Some(&ctx.observers),
646        "info",
647        "ws",
648        &offer_received_breadcrumb(
649            &job_id,
650            &claim.game_id,
651            &claim.asset_name,
652            &claim.model,
653            claim.vram_gb_estimate,
654        ),
655        Some(job_id.clone()),
656    );
657    // Operator pressed Pause: reject the offer so the studio retries
658    // on a different worker (or requeues until we resume).  No engine
659    // dispatch, no busy flag flip.
660    if ctx.paused.load(Ordering::SeqCst) {
661        push_log_with_observers(
662            &ctx.logs,
663            Some(&ctx.observers),
664            "info",
665            "ws",
666            &format!("rejecting offer {job_id}: worker is paused"),
667            Some(job_id.clone()),
668        );
669        spawn_reject_offer(
670            ctx.sender.clone(),
671            ctx.logs.clone(),
672            ctx.observers.clone(),
673            job_id,
674            "worker paused by operator",
675            crate::ws::types::RejectCode::Paused,
676        );
677        return;
678    }
679    let reservation = match crate::job_gate::JobGate::from_shared(ctx.busy.clone()).try_reserve() {
680        Some(reservation) => reservation,
681        None => {
682            push_log_with_observers(
683                &ctx.logs,
684                Some(&ctx.observers),
685                "info",
686                "ws",
687                &format!("rejecting offer {job_id}: worker is already busy"),
688                Some(job_id.clone()),
689            );
690            spawn_reject_offer(
691                ctx.sender.clone(),
692                ctx.logs.clone(),
693                ctx.observers.clone(),
694                job_id,
695                "worker already has an in-flight job",
696                crate::ws::types::RejectCode::Busy,
697            );
698            return;
699        }
700    };
701    let job = claim.into_job_claim();
702    let task_kind = job.task.kind();
703    // Mirror the studio's model into the local catalog so the local
704    // API can serve it too (studio admins add models from HF; a local
705    // API user then gets the same models).  Best-effort, never fails
706    // the job.
707    crate::runtime::sync_studio_model(&ctx.observers, &job.model, task_kind, &job.model_source);
708    // The FULL prompt goes back to the studio (and to the engine).
709    // The bounded preview (`truncate_prompt`) is only for the UI's
710    // Jobs page so the in-memory observer ring stays small even when
711    // LLM prompts are huge.  Mixing the two used to send the
712    // truncated 200-char preview as the `prompt` form field on the
713    // multipart `/complete`, which the studio then persisted onto the
714    // row — mangling every operator-facing prompt in the DB.
715    let full_prompt = prompt_for(&job.task);
716    let prompt_preview = truncate_prompt(&full_prompt);
717    let started_at = chrono::Utc::now();
718
719    let ctx = ctx.clone();
720    tokio::spawn(async move {
721        // Held for the whole job; its Drop frees the worker slot on
722        // every exit path (accept failure, success, or a panic).
723        let _reservation = reservation;
724        let accept_result = ctx
725            .sender
726            .send(&WorkerInbound::Accept {
727                job_id: job_id.clone(),
728            })
729            .await;
730        if let Some((level, message)) = offer_response_breadcrumb("accept", &job_id, &accept_result)
731        {
732            push_log_with_observers(
733                &ctx.logs,
734                Some(&ctx.observers),
735                level,
736                "ws",
737                &message,
738                Some(job_id.clone()),
739            );
740        }
741        if accept_result.is_err() {
742            return;
743        }
744
745        // Surface the job to the UI — bounded preview only.  The heartbeat
746        // reports `current_job`; `JobRun` lists it with every other job
747        // and scopes its log.
748        let current = CurrentJob {
749            job_id: job_id.clone(),
750            kind: task_kind,
751            model: job.model.clone(),
752            prompt: prompt_preview,
753            started_at,
754            source: JobSource::Studio,
755        };
756        *ctx.observers.current_job.lock() = Some(current.clone());
757        let run = JobRun::begin(&ctx.observers, current);
758        let span = run.span().clone();
759        run_offered_job(&ctx, job, run, task_kind, full_prompt)
760            .instrument(span)
761            .await;
762    });
763}
764
765fn spawn_reject_offer(
766    sender: WsSender,
767    logs: Arc<Mutex<Vec<LogEntry>>>,
768    observers: WorkerObservers,
769    job_id: String,
770    reason: &'static str,
771    code: crate::ws::types::RejectCode,
772) {
773    tokio::spawn(async move {
774        let result = sender
775            .send(&WorkerInbound::Reject {
776                job_id: job_id.clone(),
777                reason: reason.to_string(),
778                code: Some(code),
779            })
780            .await;
781        if let Some((level, message)) = offer_response_breadcrumb("reject", &job_id, &result) {
782            push_log_with_observers(&logs, Some(&observers), level, "ws", &message, Some(job_id));
783        }
784    });
785}
786
787#[cfg_attr(coverage_nightly, coverage(off))]
788async fn run_offered_job(
789    ctx: &SessionContext,
790    job: crate::types::JobClaim,
791    run: JobRun,
792    task_kind: crate::types::TaskKind,
793    full_prompt: String,
794) {
795    let start = std::time::Instant::now();
796    // Pass the studio's `ModelSource` to the engine so sd-cpp /
797    // llama-cpp know which files to load.  Required on every offer
798    // — the studio refuses to promote a job without a model source
799    // and the worker refuses any claim that lacks one.
800    let dispatch = tokio::task::spawn_blocking({
801        let model = job.model.clone();
802        let model_source = job.model_source.clone();
803        let task_for_engine = job.task.clone();
804        let engine = ctx.engine.clone();
805        let span = run.span().clone();
806        let thumbnail = run.thumbnail_keeper();
807        move || -> Result<TaskResult> {
808            let result = span
809                .in_scope(|| engine.dispatch_with_source(&model, task_for_engine, &model_source));
810            if let Ok(result) = &result {
811                thumbnail.keep(result);
812            }
813            result
814        }
815    })
816    .await;
817
818    let job_id = job.job_id.clone();
819    // Every arm produces the outcome as a value, so the compiler
820    // proves the RecentJob ring always records a real outcome — no
821    // mutable default that survives a forgotten assignment.
822    let outcome = match dispatch {
823        Ok(Ok(result)) => {
824            push_log_with_observers(
825                &ctx.logs,
826                Some(&ctx.observers),
827                "info",
828                "ws",
829                &format!("{} dispatched in {:?}", task_kind.as_str(), start.elapsed()),
830                Some(job_id.clone()),
831            );
832            deliver_result(ctx, &job_id, result, &full_prompt).await
833        }
834        Ok(Err(e)) => {
835            warn!(target: TRACE_TARGET, error = %e, "engine dispatch failed");
836            push_log_with_observers(
837                &ctx.logs,
838                Some(&ctx.observers),
839                "error",
840                "ws",
841                &format!("dispatch failed: {e}"),
842                Some(job_id.clone()),
843            );
844            let fail_result = ctx
845                .sender
846                .send(&WorkerInbound::Fail {
847                    job_id: job_id.clone(),
848                    error: e.to_string(),
849                    retryable: !is_unsupported_kind(&e),
850                })
851                .await;
852            record_fail_send(&fail_result, &job_id, &ctx.logs, &ctx.observers);
853            JobOutcome::Failed {
854                reason: e.to_string(),
855            }
856        }
857        Err(e) => {
858            push_log_with_observers(
859                &ctx.logs,
860                Some(&ctx.observers),
861                "error",
862                "ws",
863                &format!("dispatch task panic: {e}"),
864                Some(job_id.clone()),
865            );
866            let fail_result = ctx
867                .sender
868                .send(&WorkerInbound::Fail {
869                    job_id: job_id.clone(),
870                    error: e.to_string(),
871                    retryable: true,
872                })
873                .await;
874            record_fail_send(&fail_result, &job_id, &ctx.logs, &ctx.observers);
875            JobOutcome::Failed {
876                reason: e.to_string(),
877            }
878        }
879    };
880
881    // Surface the finished job to the UI: clear the current-job slot
882    // and record the job in the recent ring.
883    *ctx.observers.current_job.lock() = None;
884    run.finish(outcome);
885}
886
887/// Deliver a successful engine result to the studio and return the
888/// outcome to record.  Binary outputs travel the multipart HTTP
889/// `/complete` route (R2 doesn't fit in WS frames); JSON outputs
890/// travel the WS `completeJson` frame.
891#[cfg_attr(coverage_nightly, coverage(off))]
892async fn deliver_result(
893    ctx: &SessionContext,
894    job_id: &str,
895    result: TaskResult,
896    full_prompt: &str,
897) -> JobOutcome {
898    match result {
899        TaskResult::Image { bytes, ext }
900        | TaskResult::AudioTts { bytes, ext }
901        | TaskResult::Video { bytes, ext } => {
902            let upload_result = tokio::task::spawn_blocking({
903                let api_base_url = ctx.api_base_url.clone();
904                let job_id = job_id.to_string();
905                let auth_token = ctx.auth_token.clone();
906                let worker_id = ctx.worker_id.clone();
907                let prompt = full_prompt.to_string();
908                move || -> Result<()> {
909                    let api = ApiClient::new(api_base_url)?;
910                    api.complete_with_retry(
911                        &worker_id,
912                        &auth_token,
913                        &job_id,
914                        &ext,
915                        &prompt,
916                        bytes,
917                        UPLOAD_RETRIES,
918                        UPLOAD_RETRY_PAUSE,
919                    )
920                }
921            })
922            .await;
923            let msg = match upload_result {
924                Ok(Ok(())) => None,
925                Ok(Err(e)) => Some(e.to_string()),
926                Err(e) => Some(format!("upload task panic: {e}")),
927            };
928            match msg {
929                Some(msg) => {
930                    push_log_with_observers(
931                        &ctx.logs,
932                        Some(&ctx.observers),
933                        "error",
934                        "ws",
935                        &msg,
936                        Some(job_id.to_string()),
937                    );
938                    let fail_result = ctx
939                        .sender
940                        .send(&WorkerInbound::Fail {
941                            job_id: job_id.to_string(),
942                            error: msg.clone(),
943                            retryable: true,
944                        })
945                        .await;
946                    record_fail_send(&fail_result, job_id, &ctx.logs, &ctx.observers);
947                    JobOutcome::Failed { reason: msg }
948                }
949                None => {
950                    push_log_with_observers(
951                        &ctx.logs,
952                        Some(&ctx.observers),
953                        "info",
954                        "ws",
955                        "binary upload ok",
956                        Some(job_id.to_string()),
957                    );
958                    // The studio's HTTP `/complete` handler defers a
959                    // `notifyJobCompleted` RPC to the
960                    // WorkerConnections DO; that's the canonical
961                    // "offer next job" nudge.  Sending an extra
962                    // `ReadyForMore` here races that flow: both can
963                    // call `offerNextFor` concurrently, double-
964                    // reserve the session's `currentJob` slot, and
965                    // ship two `Offer` frames — the second `Accept`
966                    // then trips the studio's `session not
967                    // authenticated`-shaped `accept for unknown
968                    // jobId` invariant and the DO kills the
969                    // session.  See:
970                    //   apps/studio/src/worker/modules/graphics/
971                    //     WorkerConnections/orchestrator.ts (commitOffer)
972                    JobOutcome::Completed
973                }
974            }
975        }
976        TaskResult::Llm { json } | TaskResult::AudioStt { json } => {
977            // Mirror the binary path: branch on the send result so a
978            // dropped `completeJson` frame is recorded as a failure
979            // (never a false-positive `Completed`) and a successful
980            // send leaves an explicit completion breadcrumb, symmetric
981            // with the binary path's "binary upload ok".
982            match ctx
983                .sender
984                .send(&WorkerInbound::CompleteJson {
985                    job_id: job_id.to_string(),
986                    result: json,
987                    prompt: Some(full_prompt.to_string()),
988                })
989                .await
990            {
991                Ok(()) => {
992                    push_log_with_observers(
993                        &ctx.logs,
994                        Some(&ctx.observers),
995                        "info",
996                        "ws",
997                        "json result sent",
998                        Some(job_id.to_string()),
999                    );
1000                    JobOutcome::Completed
1001                }
1002                Err(e) => {
1003                    let msg = format!("failed to send result: {e}");
1004                    push_log_with_observers(
1005                        &ctx.logs,
1006                        Some(&ctx.observers),
1007                        "error",
1008                        "ws",
1009                        &msg,
1010                        Some(job_id.to_string()),
1011                    );
1012                    JobOutcome::Failed { reason: msg }
1013                }
1014            }
1015        }
1016    }
1017}
1018
1019#[cfg_attr(coverage_nightly, coverage(off))]
1020fn spawn_reader(
1021    mut receiver: crate::ws::client::WsReceiver,
1022    event_tx: mpsc::UnboundedSender<SessionEvent>,
1023    read_idle_timeout: Duration,
1024) -> tokio::task::JoinHandle<()> {
1025    tokio::spawn(async move {
1026        loop {
1027            // Bound the wait so a half-open / dead-peer socket can't block the reader forever.
1028            // A live studio acks every heartbeat (~5s), so a frame always lands well inside the
1029            // window; elapsing it means the connection is gone and the session must reconnect.
1030            match tokio::time::timeout(read_idle_timeout, receiver.recv()).await {
1031                Ok(Ok(Some(frame))) => {
1032                    if event_tx.send(SessionEvent::Frame(frame)).is_err() {
1033                        break;
1034                    }
1035                }
1036                Ok(Ok(None)) => {
1037                    let _ =
1038                        event_tx.send(SessionEvent::Disconnected(WsClientError::ConnectionClosed));
1039                    break;
1040                }
1041                Ok(Err(e)) => {
1042                    let _ = event_tx.send(SessionEvent::Disconnected(e));
1043                    break;
1044                }
1045                Err(_elapsed) => {
1046                    let _ = event_tx.send(SessionEvent::Disconnected(WsClientError::Transport(
1047                        format!(
1048                            "no frames from server for {:?}; treating connection as dead",
1049                            read_idle_timeout
1050                        ),
1051                    )));
1052                    break;
1053                }
1054            }
1055        }
1056    })
1057}
1058
1059#[cfg_attr(coverage_nightly, coverage(off))]
1060// Eight collaborators (config + engine + sender + shared flags + logs + observers + schedule);
1061// grouping them adds indirection without improving readability.
1062#[allow(clippy::too_many_arguments)]
1063fn spawn_heartbeat_pump(
1064    cfg: SharedConfig,
1065    engine: Arc<dyn Engine>,
1066    sender: WsSender,
1067    stop: Arc<AtomicBool>,
1068    paused: Arc<AtomicBool>,
1069    logs: Arc<Mutex<Vec<LogEntry>>>,
1070    observers: WorkerObservers,
1071    schedule: SessionSchedule,
1072) -> tokio::task::JoinHandle<()> {
1073    tokio::spawn(async move {
1074        let mut interval = tokio::time::interval(schedule.heartbeat);
1075        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1076        // Seed with the pause flag's value at session start so the first
1077        // tick never logs a spurious transition; only genuine operator
1078        // toggles during the session ship a breadcrumb.
1079        let mut last_paused = paused.load(Ordering::SeqCst);
1080        loop {
1081            interval.tick().await;
1082            if stop.load(Ordering::SeqCst) {
1083                break;
1084            }
1085            // A Pause / Resume from any source (Worker page, tray menu)
1086            // only emits a local `tracing` breadcrumb; ship the actual
1087            // transition so the studio's shipped-log view and the UI's
1088            // Logs page record why the worker started / stopped claiming.
1089            let now_paused = paused.load(Ordering::SeqCst);
1090            if let Some(message) = pause_transition_breadcrumb(last_paused, now_paused) {
1091                push_log_with_observers(&logs, Some(&observers), "info", "ws", message, None);
1092            }
1093            last_paused = now_paused;
1094            // Rebuild the snapshot from the live config so operator
1095            // edits (VRAM threshold, auto-start) propagate on the
1096            // next tick instead of on the next reconnect.
1097            let caps = crate::runtime::build_capabilities_with(&cfg.lock(), &*engine, !now_paused);
1098            let current_job_id = heartbeat_current_job_id(&observers);
1099            if let Err(e) = sender
1100                .send(&WorkerInbound::Heartbeat {
1101                    capabilities: caps,
1102                    current_job_id,
1103                })
1104                .await
1105            {
1106                warn!(target: TRACE_TARGET, error = %e, "heartbeat send failed");
1107                break;
1108            }
1109        }
1110    })
1111}
1112
1113fn heartbeat_current_job_id(observers: &WorkerObservers) -> Option<String> {
1114    observers
1115        .current_job
1116        .lock()
1117        .as_ref()
1118        .map(|job| job.job_id.clone())
1119}
1120
1121#[cfg_attr(coverage_nightly, coverage(off))]
1122fn spawn_log_shipper_pump(
1123    sender: WsSender,
1124    logs: Arc<Mutex<Vec<LogEntry>>>,
1125    stop: Arc<AtomicBool>,
1126    schedule: SessionSchedule,
1127) -> tokio::task::JoinHandle<()> {
1128    tokio::spawn(async move {
1129        let mut interval = tokio::time::interval(schedule.log_flush);
1130        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1131        loop {
1132            interval.tick().await;
1133            if stop.load(Ordering::SeqCst) {
1134                break;
1135            }
1136            let batch = {
1137                let mut guard = logs.lock();
1138                if guard.is_empty() {
1139                    continue;
1140                }
1141                std::mem::take(&mut *guard)
1142            };
1143            let frame = WorkerInbound::LogBatch { entries: batch };
1144            if let Err(e) = sender.send(&frame).await {
1145                warn!(target: TRACE_TARGET, error = %e, "log batch send failed; requeueing batch");
1146                // Put the batch back so it ships on the next session
1147                // instead of vanishing with this one.
1148                if let WorkerInbound::LogBatch { entries } = frame {
1149                    crate::runtime::restore_unshipped(&logs, entries);
1150                }
1151                break;
1152            }
1153        }
1154    })
1155}
1156
1157#[cfg_attr(coverage_nightly, coverage(off))]
1158fn spawn_shutdown_observer(
1159    stop: Arc<AtomicBool>,
1160    event_tx: mpsc::UnboundedSender<SessionEvent>,
1161    schedule: SessionSchedule,
1162) -> tokio::task::JoinHandle<()> {
1163    tokio::spawn(async move {
1164        loop {
1165            tokio::time::sleep(schedule.shutdown_tick).await;
1166            if stop.load(Ordering::SeqCst) {
1167                let _ = event_tx.send(SessionEvent::Stopped);
1168                break;
1169            }
1170            if event_tx.is_closed() {
1171                break;
1172            }
1173        }
1174    })
1175}
1176
1177/// Whether the reconnect loop should stop trying.  `max_attempts == 0`
1178/// means retry forever (the default) — see [`DEFAULT_RECONNECT_ATTEMPTS`].
1179/// A finite cap gives up once `attempt` exceeds it.
1180fn reconnect_exhausted(max_attempts: u32, attempt: u32) -> bool {
1181    max_attempts > 0 && attempt > max_attempts
1182}
1183
1184fn backoff_for(attempt: u32, schedule: SessionSchedule) -> Duration {
1185    let factor = 2u64.saturating_pow(attempt.saturating_sub(1));
1186    let raw_ms = schedule.base_backoff_ms.saturating_mul(factor);
1187    Duration::from_millis(raw_ms.min(schedule.max_backoff_ms))
1188}
1189
1190/// Build the operator breadcrumb for a session that dropped or never
1191/// established, surfacing the underlying error so a reconnect loop is
1192/// never opaque about *why* it's retrying.
1193///
1194/// A plain mid-session disconnect (`Ok(SessionOutcome::Disconnected)`)
1195/// carries no error and keeps the legacy "disconnected; reconnect
1196/// attempt …" wording that the studio-shipped log view and the
1197/// `ws_session_full_loop` contract test key on.  An `Err` outcome — an
1198/// engine that failed to build, or a `hello` frame that never made it
1199/// onto the wire — previously vanished into that same wording with no
1200/// cause, leaving an operator staring at an endless reconnect loop with
1201/// nothing to diagnose.  The full anyhow chain (`{:#}`) rides along so a
1202/// wrapped root cause isn't truncated to its outer context.  Pure so
1203/// the wording is unit-tested without a live WS round-trip.
1204fn reconnect_breadcrumb(error: Option<&anyhow::Error>, attempt: u32, backoff: Duration) -> String {
1205    let in_ms = backoff.as_millis();
1206    match error {
1207        Some(e) => format!("session error: {e:#}; reconnect attempt {attempt} in {in_ms}ms"),
1208        None => format!("disconnected; reconnect attempt {attempt} in {in_ms}ms"),
1209    }
1210}
1211
1212/// Operator-facing breadcrumb for the studio's `Welcome` frame.
1213///
1214/// The studio stamps `server_time` (its clock at the moment it
1215/// authenticated this worker) onto every `Welcome`, but it used to be
1216/// deserialised and dropped — the line named only the worker id. With
1217/// it surfaced, an operator can spot clock skew between the worker host
1218/// and the studio straight from the UI's Logs page and the
1219/// studio-shipped log view: skew distorts heartbeat-timeout reasoning,
1220/// auth-token expiry windows, and log-timestamp correlation across the
1221/// two sides. Pure so the wording is unit-tested without a live
1222/// welcome.
1223fn welcome_breadcrumb(worker_id: &str, server_time: &str) -> String {
1224    format!("server welcomed {worker_id} server_time={server_time}")
1225}
1226
1227/// Operator-facing breadcrumb summarising an incoming job offer.
1228///
1229/// The studio populates `game_id` + `asset_name` on every offer, but
1230/// they used to be deserialised and dropped — the line only named the
1231/// model + vram estimate, so a worker fielding offers across many games
1232/// gave no clue which game / asset each job served. Surfacing both
1233/// (data already on the wire) lets operators triage "which game's jobs
1234/// are failing on this box" straight from the UI's Logs page and the
1235/// studio-shipped log view. Pure so the wording is unit-tested without
1236/// a live offer.
1237fn offer_received_breadcrumb(
1238    job_id: &str,
1239    game_id: &str,
1240    asset_name: &str,
1241    model: &str,
1242    vram_gb_estimate: f32,
1243) -> String {
1244    format!(
1245        "offer received {job_id} game={game_id} asset={asset_name} model={model} vram={vram_gb_estimate}"
1246    )
1247}
1248
1249/// Operator-facing breadcrumb for the studio's `CompleteAck` /
1250/// `FailAck` frames.
1251///
1252/// The studio sends one of these the moment it has persisted a job's
1253/// result (the binary landed in R2, or the `completeJson` / `Fail`
1254/// frame updated the row). Both used to be silently dropped on the
1255/// "acks are best-effort; ignore" arm, so the worker's own
1256/// "binary upload ok" / completeJson breadcrumb was the last word on a
1257/// job: an operator triaging a job that ran twice (worker reported
1258/// done, studio never persisted, the job timed out + requeued) had no
1259/// signal telling them whether the studio ever acknowledged the
1260/// result. Surfacing the ack closes the job lifecycle in the UI's Logs
1261/// tab and the studio-shipped log view. `HeartbeatAck` stays unlogged:
1262/// it fires every ~5s and a genuinely missed ack already surfaces via
1263/// the read-idle timeout + reconnect breadcrumb. Pure so the wording is
1264/// unit-tested without a live ack.
1265fn result_ack_breadcrumb(outcome: &str, job_id: &str) -> String {
1266    format!("studio confirmed {outcome} of job {job_id}")
1267}
1268
1269/// Decide whether a just-attempted offer-response send (accept /
1270/// reject) warrants a session-level breadcrumb.
1271///
1272/// Returns `None` on success: the happy path is already implied by the
1273/// surrounding "dispatched" / "rejecting offer: paused" breadcrumbs, so
1274/// re-logging it would only add per-job noise.  Returns
1275/// `Some(("error", …))` when the send failed — a dropped accept leaves
1276/// the worker running a job the studio never marked accepted, and a
1277/// dropped reject leaves the offer reserved on a paused worker until it
1278/// times out.  The transport layer already logs the failure locally on
1279/// `studio_worker::ws::client`, but only a session-level breadcrumb
1280/// reaches the UI's Logs page and the studio-shipped log view with the
1281/// offending `job_id` attached.  Pure so the wording + level are
1282/// unit-tested without a live WS sink.
1283fn offer_response_breadcrumb(
1284    label: &str,
1285    job_id: &str,
1286    result: &WsResult<()>,
1287) -> Option<(&'static str, String)> {
1288    match result {
1289        Ok(()) => None,
1290        Err(e) => Some((
1291            "error",
1292            format!("{label} send failed for offer {job_id}: {e}"),
1293        )),
1294    }
1295}
1296
1297/// Decide whether a just-attempted `Fail`-frame send warrants a
1298/// session-level breadcrumb.
1299///
1300/// Returns `None` on success: the caller already logged the underlying
1301/// job failure (the upload error, dispatch error, or panic), so a `Fail`
1302/// frame that lands needs no second per-job line.  Returns
1303/// `Some(("error", …))` when the send itself failed — a dropped `Fail`
1304/// leaves the studio believing the job is still in flight (reserved on
1305/// the session's `currentJob` slot) until it times out, with no local
1306/// record that the notification never landed.  The transport layer logs
1307/// the drop locally on `studio_worker::ws::client`, but only a
1308/// session-level breadcrumb reaches the UI's Logs page and the
1309/// studio-shipped log view with the offending `job_id` attached.  Pure
1310/// so the wording + level are unit-tested without a live WS sink.
1311fn fail_send_breadcrumb(job_id: &str, result: &WsResult<()>) -> Option<(&'static str, String)> {
1312    match result {
1313        Ok(()) => None,
1314        Err(e) => Some((
1315            "error",
1316            format!("failed to notify studio of job {job_id} failure: {e}"),
1317        )),
1318    }
1319}
1320
1321/// Push a session-level breadcrumb when a `Fail`-frame send dropped.
1322///
1323/// Trivial glue over [`fail_send_breadcrumb`]: the three job-failure
1324/// arms (upload error, dispatch error, dispatch panic) all notify the
1325/// studio with a `Fail` frame and then call this, so a dropped
1326/// notification is recorded with the `job_id` attached instead of being
1327/// swallowed by `let _ = sender.send(...)`.
1328fn record_fail_send(
1329    result: &WsResult<()>,
1330    job_id: &str,
1331    logs: &Arc<Mutex<Vec<LogEntry>>>,
1332    observers: &WorkerObservers,
1333) {
1334    if let Some((level, message)) = fail_send_breadcrumb(job_id, result) {
1335        push_log_with_observers(
1336            logs,
1337            Some(observers),
1338            level,
1339            "ws",
1340            &message,
1341            Some(job_id.to_string()),
1342        );
1343    }
1344}
1345
1346/// Operator-facing breadcrumb for a change in the runtime pause flag,
1347/// or `None` when the flag is unchanged since the previous heartbeat
1348/// tick.
1349///
1350/// A Pause / Resume from the Worker page or tray menu only emits a local
1351/// `tracing` breadcrumb (stdout / Sentry) naming the source; it never
1352/// enters the worker's shipped log stream.  So the studio's shipped-log
1353/// view and the UI's Logs page used to show `auto_enabled=false`
1354/// heartbeats with no record of *why* the worker stopped claiming.  The
1355/// heartbeat pump calls this each tick and ships the transition through
1356/// `push_log_with_observers`, so a toggle from *any* source reaches the
1357/// operator-facing surfaces.  Pure so the wording is unit-tested without
1358/// driving the pump.
1359fn pause_transition_breadcrumb(prev: bool, now: bool) -> Option<&'static str> {
1360    match (prev, now) {
1361        (false, true) => Some("claiming paused by operator; new offers are rejected until resumed"),
1362        (true, false) => Some("claiming resumed by operator; accepting new offers again"),
1363        _ => None,
1364    }
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369    use super::*;
1370
1371    #[test]
1372    fn offer_response_breadcrumb_is_silent_on_success() {
1373        // The happy path is already implied by the surrounding
1374        // "dispatched" / "rejecting offer: paused" breadcrumbs, so a
1375        // successful accept / reject send must not add per-job noise.
1376        assert!(offer_response_breadcrumb("accept", "j-1", &Ok(())).is_none());
1377        assert!(offer_response_breadcrumb("reject", "j-2", &Ok(())).is_none());
1378    }
1379
1380    // (worker-reservation exclusivity now lives in `job_gate::tests`.)
1381
1382    #[test]
1383    fn heartbeat_current_job_id_uses_actual_job_id() {
1384        let observers = WorkerObservers::default();
1385        assert_eq!(heartbeat_current_job_id(&observers), None);
1386        *observers.current_job.lock() = Some(CurrentJob {
1387            job_id: "job-42".into(),
1388            kind: crate::types::TaskKind::Image,
1389            model: "synthetic".into(),
1390            prompt: "prompt".into(),
1391            started_at: chrono::Utc::now(),
1392            source: crate::runtime::JobSource::Studio,
1393        });
1394        assert_eq!(
1395            heartbeat_current_job_id(&observers).as_deref(),
1396            Some("job-42")
1397        );
1398    }
1399
1400    #[test]
1401    fn offer_response_breadcrumb_reports_accept_send_failure() {
1402        let (level, msg) =
1403            offer_response_breadcrumb("accept", "j-1", &Err(WsClientError::ConnectionClosed))
1404                .expect("a failed accept send must surface a breadcrumb");
1405        assert_eq!(level, "error");
1406        assert!(msg.contains("accept send failed"), "got: {msg}");
1407        assert!(msg.contains("j-1"), "must name the job: {msg}");
1408        assert!(
1409            msg.contains("connection closed"),
1410            "must carry the cause: {msg}"
1411        );
1412    }
1413
1414    #[test]
1415    fn offer_response_breadcrumb_reports_reject_send_failure() {
1416        let (level, msg) = offer_response_breadcrumb(
1417            "reject",
1418            "j-9",
1419            &Err(WsClientError::Transport("sink gone".into())),
1420        )
1421        .expect("a failed reject send must surface a breadcrumb");
1422        assert_eq!(level, "error");
1423        assert!(msg.contains("reject send failed"), "got: {msg}");
1424        assert!(msg.contains("j-9"), "must name the job: {msg}");
1425        assert!(msg.contains("sink gone"), "must carry the cause: {msg}");
1426    }
1427
1428    #[test]
1429    fn fail_send_breadcrumb_is_silent_on_success() {
1430        // The underlying job failure (upload / dispatch / panic) is
1431        // already logged by the caller, so a Fail-frame that lands must
1432        // not add a second per-job line.
1433        assert!(fail_send_breadcrumb("j-1", &Ok(())).is_none());
1434    }
1435
1436    #[test]
1437    fn fail_send_breadcrumb_reports_send_failure() {
1438        let (level, msg) = fail_send_breadcrumb("j-7", &Err(WsClientError::ConnectionClosed))
1439            .expect("a dropped Fail send must surface a breadcrumb");
1440        assert_eq!(level, "error");
1441        assert!(msg.contains("j-7"), "must name the job: {msg}");
1442        assert!(
1443            msg.contains("connection closed"),
1444            "must carry the cause: {msg}"
1445        );
1446    }
1447
1448    #[test]
1449    fn fail_send_breadcrumb_carries_transport_cause() {
1450        let (level, msg) =
1451            fail_send_breadcrumb("j-3", &Err(WsClientError::Transport("sink gone".into())))
1452                .expect("a dropped Fail send must surface a breadcrumb");
1453        assert_eq!(level, "error");
1454        assert!(msg.contains("j-3"), "must name the job: {msg}");
1455        assert!(msg.contains("sink gone"), "must carry the cause: {msg}");
1456    }
1457
1458    #[test]
1459    fn backoff_grows_exponentially_until_cap() {
1460        let schedule = SessionSchedule {
1461            base_backoff_ms: 100,
1462            max_backoff_ms: 1_000,
1463            heartbeat: Duration::from_secs(1),
1464            log_flush: Duration::from_secs(1),
1465            shutdown_tick: Duration::from_secs(1),
1466            read_idle_timeout: Duration::from_secs(1),
1467        };
1468        assert_eq!(backoff_for(1, schedule), Duration::from_millis(100));
1469        assert_eq!(backoff_for(2, schedule), Duration::from_millis(200));
1470        assert_eq!(backoff_for(3, schedule), Duration::from_millis(400));
1471        assert_eq!(backoff_for(4, schedule), Duration::from_millis(800));
1472        // Capped.
1473        assert_eq!(backoff_for(5, schedule), Duration::from_millis(1_000));
1474        assert_eq!(backoff_for(10, schedule), Duration::from_millis(1_000));
1475    }
1476
1477    #[test]
1478    fn reconnect_is_infinite_by_default_and_finite_only_when_pinned() {
1479        // The default (max_attempts == 0) must never give up — an
1480        // unattended worker with no service manager has to keep
1481        // reconnecting through a long outage instead of dying.
1482        assert!(!reconnect_exhausted(0, 1));
1483        assert!(!reconnect_exhausted(0, 10_000));
1484        // Regression guard: the old default of 5 killed the worker on
1485        // the 6th failure; the current default must not.
1486        assert!(!reconnect_exhausted(DEFAULT_RECONNECT_ATTEMPTS, 6));
1487        assert_eq!(DEFAULT_RECONNECT_ATTEMPTS, 0, "default must be infinite");
1488        // A pinned finite cap still gives up past the cap.
1489        assert!(!reconnect_exhausted(5, 5));
1490        assert!(reconnect_exhausted(5, 6));
1491    }
1492
1493    #[test]
1494    fn reconnect_breadcrumb_keeps_legacy_wording_for_a_plain_disconnect() {
1495        // A mid-session drop carries no error; the exact wording the
1496        // studio-shipped log view and the `ws_session_full_loop`
1497        // contract test key on must be preserved.
1498        let msg = reconnect_breadcrumb(None, 3, Duration::from_millis(800));
1499        assert_eq!(msg, "disconnected; reconnect attempt 3 in 800ms");
1500    }
1501
1502    #[test]
1503    fn reconnect_breadcrumb_surfaces_the_underlying_error() {
1504        // An `Err` outcome (engine build failure, `hello` send failure)
1505        // used to vanish into the plain "disconnected" line, leaving an
1506        // operator with an opaque endless reconnect loop. The cause must
1507        // now ride along while still naming the attempt + backoff.
1508        let err = anyhow!("hello send failed: connection closed");
1509        let msg = reconnect_breadcrumb(Some(&err), 2, Duration::from_millis(400));
1510        assert!(
1511            msg.contains("reconnect attempt 2 in 400ms"),
1512            "must still name attempt + backoff: {msg}"
1513        );
1514        assert!(
1515            msg.contains("hello send failed: connection closed"),
1516            "must carry the cause: {msg}"
1517        );
1518    }
1519
1520    #[test]
1521    fn reconnect_breadcrumb_includes_the_full_error_chain() {
1522        // anyhow context chains must reach the operator so a wrapped
1523        // root cause isn't truncated to just the outer context.
1524        let err = anyhow!("driver missing").context("engine build failed");
1525        let msg = reconnect_breadcrumb(Some(&err), 1, Duration::from_millis(100));
1526        assert!(msg.contains("engine build failed"), "got: {msg}");
1527        assert!(
1528            msg.contains("driver missing"),
1529            "must include the root cause: {msg}"
1530        );
1531    }
1532
1533    #[test]
1534    fn has_credentials_false_when_either_missing() {
1535        let mut cfg = crate::config::Config::default();
1536        let shared = crate::config::shared(cfg.clone());
1537        assert!(!has_credentials(&shared), "both missing");
1538        cfg.worker_id = Some("w-1".into());
1539        let shared = crate::config::shared(cfg.clone());
1540        assert!(!has_credentials(&shared), "only worker_id");
1541        cfg.worker_id = None;
1542        cfg.auth_token = Some("tok".into());
1543        let shared = crate::config::shared(cfg.clone());
1544        assert!(!has_credentials(&shared), "only auth_token");
1545    }
1546
1547    #[test]
1548    fn has_credentials_true_when_both_present() {
1549        let cfg = crate::config::Config {
1550            worker_id: Some("w-1".into()),
1551            auth_token: Some("tok".into()),
1552            ..crate::config::Config::default()
1553        };
1554        let shared = crate::config::shared(cfg);
1555        assert!(has_credentials(&shared));
1556    }
1557
1558    #[test]
1559    fn has_credentials_false_when_empty_strings() {
1560        let cfg = crate::config::Config {
1561            worker_id: Some("".into()),
1562            auth_token: Some("".into()),
1563            ..crate::config::Config::default()
1564        };
1565        let shared = crate::config::shared(cfg);
1566        assert!(!has_credentials(&shared));
1567    }
1568
1569    #[test]
1570    fn pause_transition_breadcrumb_is_silent_when_unchanged() {
1571        // No flag change since the previous tick — the pump must not add
1572        // a per-tick log line on every 5s heartbeat.
1573        assert!(pause_transition_breadcrumb(false, false).is_none());
1574        assert!(pause_transition_breadcrumb(true, true).is_none());
1575    }
1576
1577    #[test]
1578    fn pause_transition_breadcrumb_reports_pause_and_resume() {
1579        // A genuine operator toggle must ship an info-level breadcrumb
1580        // naming the new claiming state so the studio's shipped-log view
1581        // and the UI's Logs page record why the worker stopped / resumed.
1582        let paused = pause_transition_breadcrumb(false, true).expect("a pause must be reported");
1583        assert!(
1584            paused.contains("paused by operator"),
1585            "expected a pause message, got: {paused}"
1586        );
1587        let resumed = pause_transition_breadcrumb(true, false).expect("a resume must be reported");
1588        assert!(
1589            resumed.contains("resumed by operator"),
1590            "expected a resume message, got: {resumed}"
1591        );
1592    }
1593
1594    #[test]
1595    fn welcome_breadcrumb_surfaces_server_time() {
1596        // The studio stamps `server_time` (its clock at the moment it
1597        // authenticated this worker) onto every `Welcome`; it used to be
1598        // deserialised and dropped, so an operator couldn't spot clock
1599        // skew between the worker host and the studio. The breadcrumb
1600        // must keep the legacy "server welcomed <id>" wording and add the
1601        // server time alongside it.
1602        let line = welcome_breadcrumb("worker-7", "2026-06-15T21:00:00Z");
1603        assert!(
1604            line.contains("server welcomed worker-7"),
1605            "expected the legacy wording + worker id, got: {line}"
1606        );
1607        assert!(
1608            line.contains("server_time=2026-06-15T21:00:00Z"),
1609            "expected the server time, got: {line}"
1610        );
1611    }
1612
1613    #[test]
1614    fn offer_received_breadcrumb_names_game_and_asset() {
1615        // The studio sends `game_id` + `asset_name` on every offer; both
1616        // used to be deserialised and dropped, so an operator fielding
1617        // offers across many games couldn't tell which game / asset each
1618        // job served. The breadcrumb must surface both alongside the
1619        // model + vram estimate it already reported.
1620        let line = offer_received_breadcrumb(
1621            "j-1",
1622            "game-of-elements",
1623            "game-of-elements/creatures/aurora-fox",
1624            "sd-cpp:flux",
1625            12.5,
1626        );
1627        assert!(
1628            line.contains("offer received j-1"),
1629            "expected the job id, got: {line}"
1630        );
1631        assert!(
1632            line.contains("game=game-of-elements"),
1633            "expected the game id, got: {line}"
1634        );
1635        assert!(
1636            line.contains("asset=game-of-elements/creatures/aurora-fox"),
1637            "expected the asset name, got: {line}"
1638        );
1639        assert!(
1640            line.contains("model=sd-cpp:flux"),
1641            "expected the model, got: {line}"
1642        );
1643        assert!(line.contains("vram=12.5"), "expected the vram, got: {line}");
1644    }
1645
1646    #[test]
1647    fn result_ack_breadcrumb_names_the_outcome_and_job() {
1648        // The studio sends `CompleteAck` / `FailAck` the moment it has
1649        // persisted a job's result; both used to be silently dropped on
1650        // the "acks are best-effort; ignore" arm, so the worker's own
1651        // "binary upload ok" / completeJson line was the last word on a
1652        // job. An operator triaging a job that ran twice (worker
1653        // reported done, studio never persisted, job requeued) had no
1654        // signal telling them whether the studio acknowledged the
1655        // result. The breadcrumb must name both the outcome and the
1656        // offending job id.
1657        assert_eq!(
1658            result_ack_breadcrumb("completion", "j-1"),
1659            "studio confirmed completion of job j-1"
1660        );
1661        assert_eq!(
1662            result_ack_breadcrumb("failure", "j-2"),
1663            "studio confirmed failure of job j-2"
1664        );
1665    }
1666}