1use 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
56pub 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
162pub(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 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
315pub 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 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 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;