Skip to main content

bamboo_engine/
ticket_runtime.rs

1//! Host-only Ticket dispatch over canonical SessionInbox activation. Runtime
2//! evidence lives in the existing Child control plane, never in Worker JSON.
3use std::sync::Arc;
4
5use bamboo_domain::{Session, SessionAuthorityIdentity, SessionKind, Storage};
6use bamboo_tickets::{
7    canonical_bytes, content_hash, AssignmentState, Authority, DispatchSpec, Error, Operation,
8    Principal, Result, RuntimeReceipt, ScopeBinding, TicketService, WorkState,
9};
10use serde::{Deserialize, Serialize};
11
12use crate::execution::spawn::SessionInboxRuntimeBinding;
13
14pub const TICKET_DISPATCH_KEY: &str = "ticket.runtime.dispatch.v1";
15mod result;
16pub use result::{
17    checkpoint_owned_task_stop, checkpoint_owned_ticket_stop, checkpoint_ticket_result,
18    question_terminal_call_id,
19};
20
21#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct TicketDispatchBinding {
24    pub dispatch_key: String,
25    pub spec_hash: String,
26    pub assignment_id: String,
27    pub binding: ScopeBinding,
28    /// Immutable Host request ceiling, retained in canonical Child metadata.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub max_rounds: Option<u32>,
31    pub receipt: Option<RuntimeReceipt>,
32    pub receipt_created_at: Option<chrono::DateTime<chrono::Utc>>,
33}
34
35#[derive(Clone, Debug, Serialize)]
36#[serde(tag = "status", rename_all = "snake_case")]
37pub enum DispatchObservation {
38    Missing,
39    Pending {
40        session_id: String,
41    },
42    Admitted {
43        receipt: RuntimeReceipt,
44    },
45    Terminal {
46        receipt: RuntimeReceipt,
47        terminal_status: String,
48    },
49    OutcomeUnknown {
50        receipt: Option<RuntimeReceipt>,
51        reason: String,
52    },
53}
54
55pub fn ticket_child_id(dispatch_key: &str) -> String {
56    format!("ticket-{}", &content_hash(dispatch_key.as_bytes())[..32])
57}
58
59/// Storage's strict Root reader validates its Supervisor proof/runtime sidecar.
60/// No model/client scope token is accepted as authority.
61pub async fn verified_scope_binding(
62    storage: &dyn Storage,
63    supervisor_id: &str,
64) -> Result<(ScopeBinding, Session)> {
65    let supervisor = storage
66        .load_root_authority(supervisor_id)
67        .await?
68        .ok_or_else(|| Error::ScopeDenied("Supervisor missing".into()))?;
69    let SessionAuthorityIdentity::Supervisor { incarnation_id } = supervisor.authority_identity
70    else {
71        return Err(Error::ScopeDenied("canonical Supervisor required".into()));
72    };
73    let Some(orchestration_revision) = supervisor.supervisor_orchestration_revision() else {
74        return Err(Error::ScopeDenied(
75            "Supervisor orchestration authority required".into(),
76        ));
77    };
78    let binding = ScopeBinding {
79        scope_id: format!("supervisor/{incarnation_id}"),
80        supervisor_session_id: supervisor.id.clone(),
81        binding_revision: orchestration_revision,
82    };
83    Ok((binding, supervisor))
84}
85
86/// Existing Work keeps its sealed binding revision across thinking-mode changes.
87/// Only the strict canonical Supervisor reader can authorize that same namespace;
88/// a different incarnation, Supervisor id or a zero revision remains denied.
89pub async fn verified_scope_binding_for_service(
90    storage: &dyn Storage,
91    service: &TicketService,
92    supervisor_id: &str,
93) -> Result<(ScopeBinding, Session)> {
94    let (expected, supervisor) = verified_scope_binding(storage, supervisor_id).await?;
95    let actual = service.published()?.1.binding;
96    if actual.scope_id != expected.scope_id
97        || actual.supervisor_session_id != expected.supervisor_session_id
98        || actual.binding_revision == 0
99    {
100        return Err(Error::ScopeDenied(
101            "Supervisor scope binding changed".into(),
102        ));
103    }
104    Ok((actual, supervisor))
105}
106
107pub fn dispatch_binding(
108    service: &TicketService,
109    key: &str,
110    spec: &DispatchSpec,
111) -> Result<TicketDispatchBinding> {
112    let (_, snapshot) = service.published()?;
113    let intent = snapshot
114        .intents
115        .get(key)
116        .ok_or_else(|| Error::InvalidTransition("dispatch intent missing".into()))?;
117    let spec_hash = content_hash(&canonical_bytes(spec)?);
118    if snapshot.binding != spec.binding || intent.spec_hash != spec_hash {
119        return Err(Error::IdempotencyConflict);
120    }
121    Ok(TicketDispatchBinding {
122        dispatch_key: key.into(),
123        spec_hash,
124        assignment_id: spec.assignment_id.clone(),
125        binding: snapshot.binding,
126        max_rounds: None,
127        receipt: None,
128        receipt_created_at: None,
129    })
130}
131
132pub fn require_dispatch_permission(
133    service: &TicketService,
134    dispatch: &TicketDispatchBinding,
135) -> Result<()> {
136    if service.health() != bamboo_tickets::Health::Writable {
137        return Err(Error::AuthorityUnavailable(
138            "dispatch requires writable authority".into(),
139        ));
140    }
141    let (_, snapshot) = service.published()?;
142    let assignment = snapshot
143        .assignments
144        .get(&dispatch.assignment_id)
145        .ok_or_else(|| Error::ScopeDenied("Assignment missing".into()))?;
146    let work = &snapshot.tickets[&assignment.work_id];
147    if assignment.process_stopped
148        || snapshot.binding != dispatch.binding
149        || snapshot
150            .intents
151            .get(&dispatch.dispatch_key)
152            .is_none_or(|i| i.spec_hash != dispatch.spec_hash)
153        || assignment.dispatch_key != dispatch.dispatch_key
154        || assignment.authority_epoch != snapshot.authority_epoch
155        || work.generation != assignment.generation
156        || work.contract_revision != assignment.contract_revision
157        || work.active_assignment.as_deref() != Some(&assignment.id)
158        || work.state != WorkState::Active
159        || !matches!(
160            assignment.state,
161            AssignmentState::DispatchPending | AssignmentState::Admitted | AssignmentState::Running
162        )
163    {
164        return Err(Error::ScopeDenied(
165            "dispatch permission is stale or inactive".into(),
166        ));
167    }
168    Ok(())
169}
170
171pub fn read_dispatch(session: &Session) -> Result<Option<TicketDispatchBinding>> {
172    session
173        .metadata
174        .get(TICKET_DISPATCH_KEY)
175        .map(|raw| {
176            if raw.len() > 4096 {
177                return Err(Error::ScopeDenied("dispatch binding exceeds limit".into()));
178            }
179            Ok(serde_json::from_str(raw)?)
180        })
181        .transpose()
182}
183
184/// Ticket owns this child's result/recovery lifecycle. A short Supervisor turn
185/// must not acquire the ordinary SubAgent orphan wait on its behalf. Inspect
186/// the canonical Host control plane; a model label or a cached replica cannot
187/// confer this exemption. Explicit SubAgent waits keep their existing policy.
188/// This classifies the durable owner, not a model action: parent thinking-mode
189/// changes must not transfer an independent Work child into orphan cleanup.
190/// Final model operations still validate their complete original generation.
191pub(crate) async fn is_independent_ticket_child(
192    storage: &dyn Storage,
193    parent: &Session,
194    child_id: &str,
195) -> bool {
196    let Ok((binding, canonical_parent)) = verified_scope_binding(storage, &parent.id).await else {
197        return false;
198    };
199    if parent.kind != SessionKind::Root
200        || parent.parent_session_id.is_some()
201        || parent.root_session_id != parent.id
202        || canonical_parent.created_at != parent.created_at
203        || canonical_parent.authority_identity != parent.authority_identity
204    {
205        return false;
206    }
207    let Ok(Some(child)) = storage.load_runtime_control_plane(child_id).await else {
208        return false;
209    };
210    let Ok(Some(dispatch)) = read_dispatch(&child) else {
211        return false;
212    };
213    child.kind == SessionKind::Child
214        && child.parent_session_id.as_deref() == Some(parent.id.as_str())
215        && child.parent_created_at == Some(parent.created_at)
216        && child.root_session_id == parent.id
217        && child.id == ticket_child_id(&dispatch.dispatch_key)
218        && !dispatch.dispatch_key.is_empty()
219        && !dispatch.assignment_id.is_empty()
220        && dispatch.binding.scope_id == binding.scope_id
221        && dispatch.binding.supervisor_session_id == binding.supervisor_session_id
222        && dispatch.binding.binding_revision > 0
223        && child
224            .metadata
225            .get(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
226            == Some(&dispatch.assignment_id)
227}
228
229pub async fn query_dispatch(
230    storage: &dyn Storage,
231    service: &TicketService,
232    key: &str,
233    spec: &DispatchSpec,
234) -> Result<DispatchObservation> {
235    let expected = dispatch_binding(service, key, spec)?;
236    let (binding, _) = verified_scope_binding_for_service(
237        storage,
238        service,
239        &expected.binding.supervisor_session_id,
240    )
241    .await?;
242    if binding != expected.binding {
243        return Err(Error::ScopeDenied("Supervisor binding changed".into()));
244    }
245    let child = storage
246        .load_runtime_control_plane(&ticket_child_id(key))
247        .await?;
248    let Some(child) = child else {
249        return Ok(
250            if require_dispatch_permission(service, &expected).is_ok()
251                && service.published()?.1.assignments[&spec.assignment_id]
252                    .runtime
253                    .is_none()
254            {
255                DispatchObservation::Missing
256            } else {
257                DispatchObservation::OutcomeUnknown {
258                    receipt: None,
259                    reason: "canonical dispatch evidence unavailable".into(),
260                }
261            },
262        );
263    };
264    let actual = read_dispatch(&child)?
265        .ok_or_else(|| Error::ScopeDenied("Child lacks dispatch binding".into()))?;
266    let mut comparable = actual.clone();
267    comparable.receipt = None;
268    comparable.receipt_created_at = None;
269    comparable.max_rounds = None;
270    if comparable != expected
271        || child.kind != SessionKind::Child
272        || child.parent_session_id.as_deref() != Some(&binding.supervisor_session_id)
273        || child
274            .metadata
275            .get(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
276            != Some(&expected.assignment_id)
277    {
278        return Err(Error::ScopeDenied(
279            "canonical Child dispatch changed".into(),
280        ));
281    }
282    if let Some(receipt) = actual.receipt {
283        if receipt.dispatch_key != key
284            || receipt.spec_hash != actual.spec_hash
285            || receipt.session_id != child.id
286            || receipt.run_id.is_empty()
287            || actual.receipt_created_at != Some(child.created_at)
288        {
289            return Err(Error::ScopeDenied(
290                "canonical receipt identity changed".into(),
291            ));
292        }
293        let snapshot = service.published()?.1;
294        if snapshot.assignments[&spec.assignment_id].runtime.as_ref() != Some(&receipt) {
295            return Ok(DispatchObservation::OutcomeUnknown {
296                receipt: Some(receipt),
297                reason: "Runtime receipt prepared but Ticket admission not confirmed".into(),
298            });
299        }
300        if snapshot.assignments[&spec.assignment_id].state == AssignmentState::OutcomeUnknown {
301            // Startup may mark the Session's abandoned runner as an error.
302            // That display status cannot prove the old OS process stopped or
303            // reconcile its effects, so preserve the authoritative quarantine.
304            return Ok(DispatchObservation::OutcomeUnknown {
305                receipt: Some(receipt),
306                reason: "Ticket attempt requires Runtime stop/result reconciliation".into(),
307            });
308        }
309        let status = child.last_run_status();
310        if let Some(status @ ("completed" | "error" | "timeout" | "cancelled" | "skipped")) =
311            status.as_deref()
312        {
313            return Ok(DispatchObservation::Terminal {
314                receipt,
315                terminal_status: status.into(),
316            });
317        }
318        if require_dispatch_permission(service, &expected).is_err() {
319            return Ok(DispatchObservation::OutcomeUnknown {
320                receipt: Some(receipt),
321                reason: "old writer or revoked execution; reconciliation required".into(),
322            });
323        }
324        Ok(DispatchObservation::Admitted { receipt })
325    } else if require_dispatch_permission(service, &expected).is_ok()
326        && child.last_run_status().as_deref() == Some("pending")
327    {
328        Ok(DispatchObservation::Pending {
329            session_id: child.id,
330        })
331    } else {
332        Ok(DispatchObservation::OutcomeUnknown {
333            receipt: None,
334            reason: "dispatch has no confirmed run receipt".into(),
335        })
336    }
337}
338
339fn actual_with_receipt(
340    expected: &TicketDispatchBinding,
341    receipt: &RuntimeReceipt,
342    created_at: chrono::DateTime<chrono::Utc>,
343) -> TicketDispatchBinding {
344    TicketDispatchBinding {
345        receipt: Some(receipt.clone()),
346        receipt_created_at: Some(created_at),
347        ..expected.clone()
348    }
349}
350
351/// Called after canonical activation registration, before sending any RunSpec.
352/// The Host receipt is committed first; an uncertain Ticket publication blocks
353/// execution. No lease or Worker event can supply the Run identity.
354pub async fn admit_ticket_run(
355    service: &Arc<TicketService>,
356    runtime: &SessionInboxRuntimeBinding,
357    session: &mut Session,
358    run_id: &str,
359) -> Result<()> {
360    let dispatch = read_dispatch(session)?
361        .ok_or_else(|| Error::ScopeDenied("dispatch binding missing".into()))?;
362    require_dispatch_permission(service, &dispatch)?;
363    let (binding, _) = verified_scope_binding_for_service(
364        runtime.storage.as_ref(),
365        service,
366        &dispatch.binding.supervisor_session_id,
367    )
368    .await?;
369    if binding != dispatch.binding || !runtime.router.owns_run(&session.id, run_id).await {
370        return Err(Error::ScopeDenied(
371            "canonical activation ownership changed".into(),
372        ));
373    }
374    let store = runtime.parent_question_lock.as_ref().ok_or_else(|| {
375        Error::AuthorityUnavailable("canonical write coordinator unavailable".into())
376    })?;
377    let receipt = RuntimeReceipt {
378        dispatch_key: dispatch.dispatch_key.clone(),
379        spec_hash: dispatch.spec_hash.clone(),
380        session_id: session.id.clone(),
381        run_id: run_id.into(),
382    };
383    let saved = store
384        .mutate_runtime_session_and_publish(
385            &session.id,
386            || None,
387            |latest| {
388                let current = read_dispatch(latest)?
389                    .ok_or_else(|| Error::ScopeDenied("canonical dispatch missing".into()))?;
390                let mut comparable = current.clone();
391                comparable.receipt = None;
392                comparable.receipt_created_at = None;
393                let mut expected = dispatch.clone();
394                expected.receipt = None;
395                expected.receipt_created_at = None;
396                if latest.created_at != session.created_at
397                    || latest.parent_session_id != session.parent_session_id
398                    || latest.kind != SessionKind::Child
399                    || comparable != expected
400                    || current.receipt.as_ref().is_some_and(|r| r != &receipt)
401                    || current.receipt.is_some()
402                        && current.receipt_created_at != Some(latest.created_at)
403                {
404                    return Err(Error::ScopeDenied(
405                        "dispatch birth or receipt changed".into(),
406                    ));
407                }
408                if current.receipt.is_none() {
409                    latest.metadata.insert(
410                        TICKET_DISPATCH_KEY.into(),
411                        serde_json::to_string(&actual_with_receipt(
412                            &dispatch,
413                            &receipt,
414                            latest.created_at,
415                        ))?,
416                    );
417                    latest.metadata_version =
418                        latest.metadata_version.checked_add(1).ok_or_else(|| {
419                            Error::AuthorityUnavailable("metadata revision overflow".into())
420                        })?;
421                }
422                Ok::<_, Error>(())
423            },
424            |_| {},
425        )
426        .await??
427        .ok_or_else(|| Error::ScopeDenied("canonical Child missing".into()))?;
428    // Preserve the same Host-owned receipt in the eventual final checkpoint.
429    session.metadata.insert(
430        TICKET_DISPATCH_KEY.into(),
431        saved.metadata[TICKET_DISPATCH_KEY].clone(),
432    );
433    session.metadata_version = saved.metadata_version;
434    let authority = Authority::from_verified_host(dispatch.binding, Principal::Runtime);
435    execute_runtime_command(
436        service,
437        &authority,
438        &format!("runtime-admit/{}", dispatch.dispatch_key),
439        vec![
440            Operation::Admitted {
441                assignment_id: dispatch.assignment_id.clone(),
442                receipt,
443            },
444            Operation::Running {
445                assignment_id: dispatch.assignment_id.clone(),
446            },
447        ],
448    )?;
449    // A restarted writer may only replay a historical receipt. It must not
450    // inherit the old Worker's run permission from that receipt.
451    crate::ticket_worker_plan::TicketWorkerPlan::from_runtime_receipt(
452        service.clone(),
453        &dispatch.assignment_id,
454    )?;
455    Ok(())
456}
457
458pub(crate) fn execute_runtime_command(
459    service: &TicketService,
460    authority: &Authority,
461    id: &str,
462    operations: Vec<Operation>,
463) -> Result<bamboo_tickets::OperationReceipt> {
464    for attempt in 0..4 {
465        let command = service.prepare_command(authority, id, operations.clone())?;
466        match service.execute(authority, &command) {
467            Err(Error::RevisionConflict) if attempt < 3 => continue,
468            result => return result,
469        }
470    }
471    unreachable!()
472}
473
474#[cfg(test)]
475mod tests;