1use std::sync::{Arc, Mutex};
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
23#[ts(export, export_to = "../../../frontend/types/rpc/")]
24#[serde(rename_all = "snake_case")]
25pub enum TurnOrigin {
26 User,
28 Automated,
30 System,
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct TurnInput {
38 pub origin: TurnOrigin,
39 pub text: String,
41 pub joins_turn: Option<u64>,
45}
46
47pub const TURN_SETTLE_MS: u64 = 2_000;
52
53pub const HELD_FLUSH_JOIN_MS: u64 = 10_000;
57
58pub const ABSORBED_CAP: usize = 20;
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
65#[ts(export, export_to = "../../../frontend/types/rpc/")]
66#[serde(rename_all = "snake_case")]
67pub enum TriggerKind {
68 User,
70 Broadcast,
72 Agent,
74 Service,
76 Schedule,
78 Task,
80 System,
82}
83
84impl TriggerKind {
85 pub fn is_external(self) -> bool {
90 matches!(self, TriggerKind::Agent | TriggerKind::Service | TriggerKind::Schedule | TriggerKind::Task)
91 }
92}
93
94#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
98#[ts(export, export_to = "../../../frontend/types/rpc/")]
99pub struct TurnTrigger {
100 pub kind: TriggerKind,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 #[ts(optional)]
104 pub from: Option<String>,
105 #[serde(default)]
107 pub external: bool,
108}
109
110impl TurnTrigger {
111 fn of(kind: TriggerKind, from: Option<&str>) -> Self {
112 TurnTrigger { kind, from: from.map(str::to_string), external: kind.is_external() }
113 }
114
115 pub fn from_input(origin: TurnOrigin, text: &str) -> Self {
117 match origin {
118 TurnOrigin::User if text.contains("[BROADCAST:FROM=user VIA=swarm") => Self::of(TriggerKind::Broadcast, None),
121 TurnOrigin::User => Self::of(TriggerKind::User, None),
122 TurnOrigin::System => Self::of(TriggerKind::System, None),
123 TurnOrigin::Automated => match jekt_sender(text) {
124 Some(from @ ("cron" | "loop")) => Self::of(TriggerKind::Schedule, Some(from)),
125 Some(from) if from.ends_with("-consumer") => Self::of(TriggerKind::Service, Some(from)),
126 from => Self::of(TriggerKind::Agent, from),
127 },
128 }
129 }
130
131 pub fn task(summary: Option<&str>) -> Self {
134 let summary = summary.map(str::trim).filter(|s| !s.is_empty()).map(|s| {
135 if s.chars().count() > 160 {
136 format!("{}…", s.chars().take(159).collect::<String>())
137 } else {
138 s.to_string()
139 }
140 });
141 TurnTrigger { kind: TriggerKind::Task, from: summary, external: TriggerKind::Task.is_external() }
142 }
143}
144
145fn jekt_sender(text: &str) -> Option<&str> {
150 let tag = &text[text.find("[JEKT:")? + "[JEKT:".len()..];
151 let tag = &tag[..tag.find(']')?];
152 tag.split_whitespace().find_map(|t| t.strip_prefix("FROM=")).filter(|s| !s.is_empty())
153}
154
155#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
157#[ts(export, export_to = "../../../frontend/types/rpc/")]
158#[serde(rename_all = "snake_case")]
159pub enum TurnEnd {
160 Completed,
162 Exited,
164}
165
166#[derive(Clone, Debug, PartialEq, serde::Serialize, ts_rs::TS)]
171#[ts(export, export_to = "../../../frontend/types/rpc/")]
172pub struct TurnLedger {
173 #[ts(type = "number")]
175 pub turn_id: u64,
176 #[ts(type = "number")]
180 pub seq: u64,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 #[ts(optional)]
184 pub origin: Option<TurnOrigin>,
185 #[serde(skip_serializing_if = "Option::is_none")]
187 #[ts(optional)]
188 pub trigger: Option<TurnTrigger>,
189 #[serde(skip_serializing_if = "Vec::is_empty")]
192 #[ts(optional, as = "Option<Vec<TurnTrigger>>")]
193 pub absorbed: Vec<TurnTrigger>,
194 #[ts(type = "number")]
195 pub started_at_ms: u64,
196 pub passes: u32,
198 pub active: bool,
200 pub inputs: u32,
203 pub counted_passes: u32,
207 #[ts(type = "number")]
209 pub output_tokens: u64,
210 pub cost_usd: f64,
211 pub steps: u32,
213 #[ts(type = "number")]
214 pub duration_api_ms: u64,
215 #[serde(skip_serializing_if = "Option::is_none")]
216 #[ts(optional, type = "number")]
217 pub last_pass_ended_at_ms: Option<u64>,
218 #[serde(skip_serializing_if = "Option::is_none")]
221 #[ts(optional, type = "number")]
222 pub settle_until_ms: Option<u64>,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 #[ts(optional, type = "number")]
225 pub ended_at_ms: Option<u64>,
226 #[serde(skip_serializing_if = "Option::is_none")]
227 #[ts(optional)]
228 pub end: Option<TurnEnd>,
229}
230
231impl TurnLedger {
232 fn absorb(&mut self, trigger: Option<TurnTrigger>) {
233 if let Some(t) = trigger.filter(|_| self.absorbed.len() < ABSORBED_CAP) {
234 self.absorbed.push(t);
235 }
236 }
237
238 fn add_stats(&mut self, stats: PassStats) {
239 self.counted_passes += 1;
240 self.output_tokens += stats.output_tokens;
241 self.cost_usd += stats.cost_usd;
242 self.steps += stats.steps;
243 self.duration_api_ms += stats.duration_api_ms;
244 }
245
246 fn settling_at(&self, now: u64) -> bool {
249 !self.active && self.ended_at_ms.is_none() && self.settle_until_ms.is_some_and(|u| now <= u)
250 }
251
252 fn ended_at(&self, now: u64) -> Option<u64> {
256 self.ended_at_ms
257 .or_else(|| (!self.active && !self.settling_at(now)).then_some(self.last_pass_ended_at_ms).flatten())
258 }
259
260 fn close_if_lapsed(&mut self, now: u64) {
263 if self.ended_at_ms.is_none() && !self.active && !self.settling_at(now) {
264 self.ended_at_ms = Some(self.last_pass_ended_at_ms.unwrap_or(now));
265 self.end = Some(TurnEnd::Completed);
266 self.settle_until_ms = None;
267 }
268 }
269}
270
271#[derive(Clone, Copy, Debug, Default, PartialEq)]
273pub struct PassStats {
274 pub output_tokens: u64,
275 pub cost_usd: f64,
276 pub steps: u32,
277 pub duration_api_ms: u64,
278}
279
280impl PassStats {
281 pub fn from_result_frame(frame: &serde_json::Value) -> Self {
283 let u64_at = |v: Option<&serde_json::Value>| v.and_then(|v| v.as_u64()).unwrap_or(0);
284 PassStats {
285 output_tokens: u64_at(frame.pointer("/usage/output_tokens")),
286 cost_usd: frame.get("total_cost_usd").and_then(|v| v.as_f64()).unwrap_or(0.0),
287 steps: u64_at(frame.get("num_turns")) as u32,
288 duration_api_ms: u64_at(frame.get("duration_api_ms")),
289 }
290 }
291}
292
293#[derive(Clone, Copy, Debug, PartialEq, Eq)]
295enum PassStart {
296 Input { origin: TurnOrigin, joins_turn: Option<u64> },
298 Queued,
300 CliWake,
302}
303
304#[derive(Clone, Debug, serde::Serialize, ts_rs::TS)]
306#[ts(export, export_to = "../../../frontend/types/rpc/")]
307pub struct AgentTurnEvent {
308 pub block_id: String,
309 #[serde(flatten)]
310 pub ledger: TurnLedger,
311}
312
313pub type TurnLedgerPublisher = Arc<dyn Fn(&TurnLedger) + Send + Sync>;
315
316pub fn ledger_publisher(broker: Arc<crate::backend::mps::Broker>, block_id: String) -> TurnLedgerPublisher {
324 let latest_seq = Arc::new(std::sync::atomic::AtomicU64::new(0));
325 let publish = Arc::new(move |ledger: &TurnLedger| {
326 let payload = AgentTurnEvent { block_id: block_id.clone(), ledger: ledger.clone() };
327 broker.publish_event::<crate::backend::mps_events::AgentTurn>(&payload, vec![format!("block:{block_id}")], 1);
328 });
329 Arc::new(move |ledger: &TurnLedger| {
330 latest_seq.store(ledger.seq, std::sync::atomic::Ordering::SeqCst);
331 publish(ledger);
332 if let (Some(closed), Ok(handle)) = (closed_when_lapsed(ledger), tokio::runtime::Handle::try_current()) {
333 let wait = ledger.settle_until_ms.unwrap_or(0).saturating_sub(agentmux_common::time::now_ms_u64()) + 50;
334 let (latest_seq, publish) = (Arc::clone(&latest_seq), Arc::clone(&publish));
335 handle.spawn(async move {
336 tokio::time::sleep(std::time::Duration::from_millis(wait)).await;
337 if latest_seq.load(std::sync::atomic::Ordering::SeqCst) == closed.seq {
338 publish(&closed);
339 }
340 });
341 }
342 })
343}
344
345pub fn ledger_transition(before: Option<&TurnLedger>, after: Option<&TurnLedger>) -> Option<&'static str> {
348 let a = after?;
349 let Some(b) = before.filter(|b| b.turn_id == a.turn_id) else {
350 return Some("opened");
351 };
352 if a.passes > b.passes {
353 return Some("next pass joined the turn");
354 }
355 if a.inputs > b.inputs {
356 return Some("input joined the running pass");
357 }
358 if a.end.is_some() && b.end.is_none() {
359 return Some(match a.end {
360 Some(TurnEnd::Exited) => "ended: the process exited",
361 _ => "ended",
362 });
363 }
364 if a.settle_until_ms.is_some() && b.settle_until_ms.is_none() {
365 return Some("pass ended, settling for a next one");
366 }
367 None
368}
369
370pub fn closed_when_lapsed(ledger: &TurnLedger) -> Option<TurnLedger> {
374 let lapsed_at = ledger.settle_until_ms?.saturating_add(1);
375 if ledger.active || ledger.ended_at_ms.is_some() {
376 return None;
377 }
378 let mut closed = ledger.clone();
379 closed.close_if_lapsed(lapsed_at);
380 Some(closed)
381}
382
383#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
385pub struct TurnProvenance {
386 pub origin: TurnOrigin,
387 pub tainted: bool,
390 #[serde(skip)]
392 pub user_text: Option<String>,
393}
394
395impl TurnProvenance {
396 fn from_input(input: TurnInput) -> Self {
397 let user_text = (input.origin == TurnOrigin::User).then_some(input.text);
398 TurnProvenance { origin: input.origin, tainted: false, user_text }
399 }
400 fn absorb(&mut self, origin: TurnOrigin) {
401 if origin != TurnOrigin::User {
402 self.tainted = true;
403 }
404 }
405}
406
407struct TurnActivityTrackerInner {
408 active_turn: bool,
409 exit_code: Option<i32>,
410 provenance: Option<TurnProvenance>,
414 next_turn: Option<TurnProvenance>,
417 ledger: Option<TurnLedger>,
419 input_during_pass: bool,
422 cli_wake_pending: bool,
425 continuation_expected: bool,
429 continuation_provenance: Option<TurnProvenance>,
432 pending_join: Option<(u64, u64)>,
435 ledger_seq: u64,
437 next_turn_trigger: Option<TurnTrigger>,
439 pending_task_trigger: Option<TurnTrigger>,
441}
442
443impl TurnActivityTrackerInner {
444 fn stamp(&mut self, before: &Option<TurnLedger>) -> Option<TurnLedger> {
447 if self.ledger != *before {
448 self.ledger_seq += 1;
449 if let Some(l) = self.ledger.as_mut() {
450 l.seq = self.ledger_seq;
451 }
452 }
453 self.ledger.clone()
454 }
455
456 fn start_unlabelled(&mut self) {
458 self.provenance = self.next_turn.take();
459 }
460
461 fn note_input_during_pass(&mut self, trigger: Option<TurnTrigger>) {
463 self.input_during_pass = true;
464 self.pending_join = None;
467 if let Some(l) = self.ledger.as_mut().filter(|l| l.active) {
468 l.inputs += 1;
469 l.absorb(trigger);
470 }
471 }
472
473 fn begin_pass(&mut self, start: PassStart, trigger: Option<TurnTrigger>, now: u64) {
476 self.input_during_pass = false;
477 self.cli_wake_pending = false;
478 self.continuation_expected = false;
479 self.continuation_provenance = None;
480 let hinted = self
483 .pending_join
484 .take()
485 .filter(|(_, at)| now.saturating_sub(*at) <= HELD_FLUSH_JOIN_MS)
486 .map(|(id, _)| id);
487 let trigger = match start {
488 PassStart::Queued => trigger.or_else(|| self.next_turn_trigger.take()),
489 PassStart::CliWake => self.pending_task_trigger.take().or(trigger),
490 PassStart::Input { .. } => trigger,
491 };
492 self.pending_task_trigger = None;
495 let origin = match start {
496 PassStart::Input { origin, .. } => Some(origin),
497 PassStart::CliWake => Some(TurnOrigin::Automated),
498 PassStart::Queued => self.provenance.as_ref().map(|p| p.origin),
499 };
500 if let Some(ledger) = self.ledger.as_mut() {
501 let settling = ledger.settling_at(now);
502 let recently_ended = ledger.ended_at(now).is_some_and(|e| now.saturating_sub(e) <= HELD_FLUSH_JOIN_MS);
503 let joins = match start {
504 PassStart::Input { origin: TurnOrigin::User, joins_turn } => {
508 joins_turn == Some(ledger.turn_id) && (settling || recently_ended)
509 }
510 PassStart::Queued if hinted == Some(ledger.turn_id) => settling || recently_ended,
511 PassStart::Input { .. } | PassStart::Queued | PassStart::CliWake => settling,
512 };
513 if joins {
514 ledger.passes += 1;
515 let task_wake = matches!(start, PassStart::CliWake) && trigger.is_some();
518 if matches!(start, PassStart::Input { .. }) || task_wake {
519 ledger.inputs += 1;
520 ledger.absorb(trigger);
521 }
522 ledger.active = true;
523 ledger.settle_until_ms = None;
524 ledger.ended_at_ms = None;
525 ledger.end = None;
526 return;
527 }
528 ledger.close_if_lapsed(now);
529 }
530 let previous = self.ledger.as_ref().map_or(0, |l| l.turn_id);
531 self.ledger = Some(TurnLedger {
532 turn_id: now.max(previous + 1),
533 seq: 0,
534 origin,
535 trigger: trigger.or_else(|| {
538 (!matches!(start, PassStart::CliWake)).then_some(origin).flatten().map(|o| TurnTrigger::from_input(o, ""))
539 }),
540 absorbed: Vec::new(),
541 started_at_ms: now,
542 passes: 1,
543 active: true,
544 inputs: 0,
545 counted_passes: 0,
546 output_tokens: 0,
547 cost_usd: 0.0,
548 steps: 0,
549 duration_api_ms: 0,
550 last_pass_ended_at_ms: None,
551 settle_until_ms: None,
552 ended_at_ms: None,
553 end: None,
554 });
555 }
556
557 fn end_pass(&mut self, now: u64, provenance: Option<TurnProvenance>) {
560 let expects_more = self.input_during_pass || self.cli_wake_pending;
561 self.continuation_expected = self.input_during_pass;
562 self.continuation_provenance = self.input_during_pass.then_some(provenance).flatten();
563 self.input_during_pass = false;
564 let Some(ledger) = self.ledger.as_mut().filter(|l| l.active) else { return };
565 ledger.active = false;
566 ledger.last_pass_ended_at_ms = Some(now);
567 if expects_more {
568 ledger.settle_until_ms = Some(now + TURN_SETTLE_MS);
569 } else {
570 ledger.ended_at_ms = Some(now);
571 ledger.end = Some(TurnEnd::Completed);
572 }
573 }
574}
575
576pub struct TurnActivityTracker {
578 block_id: String,
579 inner: Mutex<TurnActivityTrackerInner>,
580 publisher: Mutex<Option<TurnLedgerPublisher>>,
581 publish_order: Mutex<()>,
586 clock: Box<dyn Fn() -> u64 + Send + Sync>,
587}
588
589impl TurnActivityTracker {
590 pub fn new(block_id: String) -> Self {
591 Self::with_clock(block_id, Box::new(agentmux_common::time::now_ms_u64))
592 }
593
594 pub fn for_block(block_id: String, broker: Option<&Arc<crate::backend::mps::Broker>>) -> Self {
596 let tracker = Self::new(block_id.clone());
597 if let Some(broker) = broker {
598 tracker.set_ledger_publisher(ledger_publisher(Arc::clone(broker), block_id));
599 }
600 tracker
601 }
602
603 fn with_clock(block_id: String, clock: Box<dyn Fn() -> u64 + Send + Sync>) -> Self {
604 Self {
605 block_id,
606 inner: Mutex::new(TurnActivityTrackerInner {
607 active_turn: false,
608 exit_code: None,
609 provenance: None,
610 next_turn: None,
611 ledger: None,
612 input_during_pass: false,
613 cli_wake_pending: false,
614 continuation_expected: false,
615 continuation_provenance: None,
616 pending_join: None,
617 ledger_seq: 0,
618 next_turn_trigger: None,
619 pending_task_trigger: None,
620 }),
621 publisher: Mutex::new(None),
622 publish_order: Mutex::new(()),
623 clock,
624 }
625 }
626
627 pub fn set_ledger_publisher(&self, publisher: TurnLedgerPublisher) {
630 *self.publisher.lock().unwrap() = Some(publisher);
631 }
632
633 fn publish_in_order(
637 &self,
638 inner: std::sync::MutexGuard<'_, TurnActivityTrackerInner>,
639 before: Option<TurnLedger>,
640 after: Option<TurnLedger>,
641 ) {
642 if before == after {
643 return;
644 }
645 let _order = self.publish_order.lock().unwrap();
646 drop(inner);
647 if let (Some(what), Some(l)) = (ledger_transition(before.as_ref(), after.as_ref()), after.as_ref()) {
648 tracing::info!(
649 block_id = %self.block_id,
650 turn_id = l.turn_id,
651 passes = l.passes,
652 inputs = l.inputs,
653 trigger = ?l.trigger.as_ref().map(|t| t.kind),
654 "[turn] {what}"
655 );
656 }
657 self.publish_if_changed(before, after);
658 }
659
660 fn publish_if_changed(&self, before: Option<TurnLedger>, after: Option<TurnLedger>) {
663 if before == after {
664 return;
665 }
666 let Some(ledger) = after else { return };
667 let publisher = self.publisher.lock().unwrap().clone();
668 if let Some(p) = publisher {
669 p(&ledger);
670 }
671 }
672
673 pub fn set_active_turn(&self, active: bool) {
675 if !active {
676 return self.end_pass(None);
677 }
678 let now = (self.clock)();
679 let mut inner = self.inner.lock().unwrap();
680 let before = inner.ledger.clone();
681 let was_active = inner.active_turn;
682 inner.active_turn = true;
683 inner.exit_code = None;
684 if !was_active {
685 inner.start_unlabelled();
686 inner.begin_pass(PassStart::Queued, None, now);
687 }
688 let after = inner.stamp(&before);
689 self.publish_in_order(inner, before, after);
690 tracing::info!(block_id = %self.block_id, active, "[health] turn_active flip");
691 }
692
693 pub fn mark_turn_active_returning_was_active(&self) -> bool {
702 self.mark_turn_active_from(None)
703 }
704
705 pub fn mark_turn_active_from(&self, input: Option<TurnInput>) -> bool {
712 let now = (self.clock)();
713 let mut inner = self.inner.lock().unwrap();
714 let before = inner.ledger.clone();
715 let was_active = inner.active_turn;
716 inner.active_turn = true;
717 inner.exit_code = None;
718 match (was_active, input) {
719 (false, Some(i)) => {
720 let start = PassStart::Input { origin: i.origin, joins_turn: i.joins_turn };
721 let trigger = TurnTrigger::from_input(i.origin, &i.text);
722 inner.provenance = Some(TurnProvenance::from_input(i));
723 inner.begin_pass(start, Some(trigger), now);
724 }
725 (false, None) => {
726 inner.start_unlabelled();
727 inner.begin_pass(PassStart::Queued, None, now);
728 }
729 (true, Some(i)) => {
730 if let Some(p) = inner.provenance.as_mut() {
731 p.absorb(i.origin);
732 }
733 inner.note_input_during_pass(Some(TurnTrigger::from_input(i.origin, &i.text)));
734 }
735 (true, None) => {
736 if let Some(p) = inner.provenance.as_mut() {
737 p.tainted = true;
738 }
739 inner.note_input_during_pass(None);
740 }
741 }
742 let after = inner.stamp(&before);
743 self.publish_in_order(inner, before, after);
744 tracing::info!(
745 block_id = %self.block_id,
746 active = true,
747 was_active,
748 "[health] turn_active flip"
749 );
750 was_active
751 }
752
753 pub fn note_cli_task_notification(&self, summary: Option<&str>) {
757 let mut inner = self.inner.lock().unwrap();
758 inner.cli_wake_pending = true;
759 inner.pending_task_trigger = Some(TurnTrigger::task(summary));
760 }
761
762 pub fn mark_turn_active_from_cli(&self) -> bool {
769 let now = (self.clock)();
770 let mut inner = self.inner.lock().unwrap();
771 let continuing = inner.continuation_expected && inner.ledger.as_ref().is_some_and(|l| l.settling_at(now));
774 if inner.active_turn || !(inner.cli_wake_pending || continuing) {
775 return false;
776 }
777 let before = inner.ledger.clone();
778 inner.active_turn = true;
779 inner.exit_code = None;
780 let continued = continuing && !inner.cli_wake_pending;
784 let carried = inner.continuation_provenance.take().filter(|_| continued);
785 inner.provenance = Some(carried.unwrap_or(TurnProvenance {
786 origin: TurnOrigin::Automated,
787 tainted: false,
788 user_text: None,
789 }));
790 inner.begin_pass(PassStart::CliWake, None, now);
791 let after = inner.stamp(&before);
792 self.publish_in_order(inner, before, after);
793 tracing::info!(block_id = %self.block_id, active = true, "[health] turn_active flip (CLI started a pass)");
794 true
795 }
796
797 pub fn end_pass(&self, stats: Option<PassStats>) {
801 let now = (self.clock)();
802 let mut inner = self.inner.lock().unwrap();
803 let before = inner.ledger.clone();
804 let was_active = inner.active_turn;
805 inner.active_turn = false;
806 let ended = inner.provenance.take();
807 if let (Some(stats), Some(l)) = (stats, inner.ledger.as_mut().filter(|l| l.active)) {
808 l.add_stats(stats);
809 }
810 if was_active {
811 inner.end_pass(now, ended);
812 }
813 let after = inner.stamp(&before);
814 self.publish_in_order(inner, before, after);
815 tracing::info!(block_id = %self.block_id, active = false, "[health] turn_active flip");
816 }
817
818 #[cfg(test)]
820 pub fn add_pass_stats(&self, stats: PassStats) {
821 let mut inner = self.inner.lock().unwrap();
822 let before = inner.ledger.clone();
823 if let Some(l) = inner.ledger.as_mut() {
824 l.add_stats(stats);
825 }
826 let after = inner.stamp(&before);
827 self.publish_in_order(inner, before, after);
828 }
829
830 pub fn hint_join(&self, turn_id: u64) {
835 let now = (self.clock)();
836 self.inner.lock().unwrap().pending_join = Some((turn_id, now));
837 }
838
839 pub fn clear_join_hint(&self) {
842 self.inner.lock().unwrap().pending_join = None;
843 }
844
845 #[cfg(test)]
847 pub fn ledger(&self) -> Option<TurnLedger> {
848 self.inner.lock().unwrap().ledger.clone()
849 }
850
851 pub fn set_exited(&self, exit_code: i32) {
853 let now = (self.clock)();
854 let mut inner = self.inner.lock().unwrap();
855 let before = inner.ledger.clone();
856 inner.active_turn = false;
857 inner.exit_code = Some(exit_code);
858 inner.provenance = None;
859 inner.next_turn = None;
860 inner.input_during_pass = false;
861 inner.cli_wake_pending = false;
862 inner.continuation_expected = false;
863 inner.continuation_provenance = None;
864 inner.pending_join = None;
865 inner.next_turn_trigger = None;
866 inner.pending_task_trigger = None;
867 if let Some(l) = inner.ledger.as_mut().filter(|l| l.ended_at_ms.is_none()) {
868 if l.active {
869 l.last_pass_ended_at_ms = Some(now);
870 }
871 l.active = false;
872 l.settle_until_ms = None;
873 l.ended_at_ms = Some(now);
874 l.end = Some(TurnEnd::Exited);
875 }
876 let after = inner.stamp(&before);
877 self.publish_in_order(inner, before, after);
878 tracing::info!(block_id = %self.block_id, exit_code, "[health] turn_active flip (process exited)");
879 }
880
881 pub fn is_active_turn(&self) -> bool {
883 self.inner.lock().unwrap().active_turn
884 }
885
886 pub fn hint_next_turn(&self, input: TurnInput) {
890 let now = (self.clock)();
891 let mut inner = self.inner.lock().unwrap();
892 if let Some(turn_id) = input.joins_turn {
895 inner.pending_join = Some((turn_id, now));
896 }
897 if inner.next_turn.is_none() {
898 inner.next_turn_trigger = Some(TurnTrigger::from_input(input.origin, &input.text));
899 }
900 match inner.next_turn.as_mut() {
901 None => inner.next_turn = Some(TurnProvenance::from_input(input)),
902 Some(p) => p.absorb(input.origin),
903 }
904 }
905
906 pub fn hint_next_turn_unlabelled(&self) {
910 if let Some(p) = self.inner.lock().unwrap().next_turn.as_mut() {
911 p.tainted = true;
912 }
913 }
914
915 pub fn mark_turn_active_for_queued(&self) -> bool {
921 let now = (self.clock)();
922 let mut inner = self.inner.lock().unwrap();
923 let before = inner.ledger.clone();
924 let was_active = inner.active_turn;
925 inner.active_turn = true;
926 inner.exit_code = None;
927 if !was_active {
928 inner.start_unlabelled();
929 inner.begin_pass(PassStart::Queued, None, now);
930 }
931 let after = inner.stamp(&before);
932 self.publish_in_order(inner, before, after);
933 tracing::info!(block_id = %self.block_id, active = true, was_active, "[health] turn_active flip (queued delivery)");
934 was_active
935 }
936
937 pub fn provenance(&self) -> Option<TurnProvenance> {
939 let inner = self.inner.lock().unwrap();
940 if inner.active_turn {
941 inner.provenance.clone()
942 } else {
943 None
944 }
945 }
946}
947
948#[cfg(test)]
949mod tests;