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