Skip to main content

agentmux_srv/backend/blockcontroller/
health.rs

1// Copyright 2025, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Per-block turn-activity tracking (is a turn currently in flight, and
5//! the last process exit code). Previously part of a larger "agent health"
6//! detector that also did silence-based unresponsive detection; that
7//! detection logic was removed (see
8//! docs/specs/SPEC_REMOVE_AGENT_UNRESPONSIVE_DETECTION_2026_08_25.md) —
9//! this struct keeps only the turn-active bookkeeping, which several other
10//! subsystems depend on independently of the removed detector
11//! (`broker::process::lifecycle_from`, the Swarm pane's running/idle
12//! badge, subagent-watcher reconciliation, `muxspect describe`).
13//!
14//! It also keeps the **turn ledger**: the turn as the user sees it, which
15//! can span several CLI passes (`TurnLedger`,
16//! docs/specs/SPEC_AGENT_TURN_MODEL_AND_LIVE_STATUS_2026_10_08.md §3, §4.3).
17//! `active_turn` stays per pass; the ledger is display data on top of it.
18
19use std::sync::{Arc, Mutex};
20
21/// Who delivered input to an agent (SPEC_AGENT_SELF_QUIT_2026_09_24.md §6.3).
22#[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    /// The human typing in the agent's own pane.
27    User,
28    /// A jekt, cron, nudge, broadcast, loop, another agent — any automated sender.
29    Automated,
30    /// srv or the frontend on its own behalf: hidden memory reinjection, a
31    /// side question.
32    System,
33}
34
35/// One input, as told to the tracker by the path that delivers it.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct TurnInput {
38    pub origin: TurnOrigin,
39    /// The message text (kept only for `User`, for the §6.3 quote check).
40    pub text: String,
41    /// The pane flushing a message it held while this turn ran: the turn's
42    /// id, so the message joins that turn instead of starting a new one
43    /// (turn-model spec §4.3, J3). Display only; provenance ignores it.
44    pub joins_turn: Option<u64>,
45}
46
47/// How long after a pass ends a turn that expects more (input arrived
48/// during the pass, or the CLI has a task notification queued) waits for its
49/// next pass before it counts as over (turn-model spec §4.3, J4). The CLI
50/// starts that pass within milliseconds; this is margin.
51pub const TURN_SETTLE_MS: u64 = 2_000;
52
53/// How long after a turn ended the pane's flush of a message it held during
54/// that turn still joins it (§4.3, J3). The flush follows the idle push at
55/// once; this covers a slow round trip.
56pub const HELD_FLUSH_JOIN_MS: u64 = 10_000;
57
58/// At most this many inputs are listed on a turn (`TurnLedger::absorbed`);
59/// `inputs` keeps counting past it.
60pub const ABSORBED_CAP: usize = 20;
61
62/// What kind of thing started, or joined, a turn (turn-model spec §5.2).
63/// Display data next to [`TurnOrigin`], which stays the security class.
64#[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    /// The human, in this pane.
69    User,
70    /// The human, by Swarm broadcast.
71    Broadcast,
72    /// A jekt from another agent.
73    Agent,
74    /// A jekt from a service (`*-consumer`: GitHub review notices, …).
75    Service,
76    /// A scheduled delivery (cron, a loop).
77    Schedule,
78    /// The CLI woke for a background task it had started.
79    Task,
80    /// AgentMux on its own behalf (memory reinjection, a side question).
81    System,
82}
83
84impl TriggerKind {
85    /// Started by something other than the user: another agent, a service, a
86    /// schedule, a background task (spec §5.2). The one place this is decided:
87    /// srv publishes it on every trigger (`TurnTrigger::external`) and the
88    /// pane and the notification router read that.
89    pub fn is_external(self) -> bool {
90        matches!(self, TriggerKind::Agent | TriggerKind::Service | TriggerKind::Schedule | TriggerKind::Task)
91    }
92}
93
94/// What started, or joined, a turn: its kind, and who or what, for display.
95/// Built by srv from what it wrote itself (the jekt marker it composed, the
96/// CLI's task notification), never from message body text.
97#[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    /// The sender (a jekt's `FROM`), or the task's summary. Absent for the user.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    #[ts(optional)]
104    pub from: Option<String>,
105    /// `kind.is_external()`, carried so readers don't keep their own list.
106    #[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    /// Classify an input by its origin and the envelope srv put on it.
116    pub fn from_input(origin: TurnOrigin, text: &str) -> Self {
117        match origin {
118            // srv rewrites a broadcast marker found inside any jekt, so only a
119            // user-origin input can carry a real one.
120            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    /// The CLI woke for a finished background task; `summary` is the
132    /// notification's own (`Background command "…" completed (exit code 0)`).
133    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
145/// The `FROM=` of the `[JEKT:…]` marker srv puts at the head of every
146/// automated delivery (cron fires as `FROM=cron`). The input may be the
147/// plain text or the stream-json line carrying it, so the marker is looked
148/// for anywhere; only automated input is read this way.
149fn 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/// Why a turn ended.
156#[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    /// Its last pass finished and nothing joined it.
161    Completed,
162    /// The agent's process exited.
163    Exited,
164}
165
166/// The turn as the user sees it: from leaving idle to returning to it with
167/// nothing queued, over however many CLI passes that took (turn-model spec
168/// §3.1, §4.3). Published as `agentturn` on every change; the pane's parser is
169/// typed against the generated `frontend/types/rpc/TurnLedger.ts`.
170#[derive(Clone, Debug, PartialEq, serde::Serialize, ts_rs::TS)]
171#[ts(export, export_to = "../../../frontend/types/rpc/")]
172pub struct TurnLedger {
173    /// Unique per block: the start time in ms, bumped past the previous id.
174    #[ts(type = "number")]
175    pub turn_id: u64,
176    /// Bumped on every change, under the tracker's lock. Publishes run after
177    /// the lock is released, so two can land out of order; the pane keeps the
178    /// highest `seq` (#4492).
179    #[ts(type = "number")]
180    pub seq: u64,
181    /// What started the turn; `None` when its first pass was unlabelled.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    #[ts(optional)]
184    pub origin: Option<TurnOrigin>,
185    /// The same, for display: what kind of thing, and who.
186    #[serde(skip_serializing_if = "Option::is_none")]
187    #[ts(optional)]
188    pub trigger: Option<TurnTrigger>,
189    /// The labelled inputs that arrived after the turn started, in order,
190    /// up to [`ABSORBED_CAP`].
191    #[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    /// CLI passes so far, the running one included.
197    pub passes: u32,
198    /// A pass is running now.
199    pub active: bool,
200    /// Inputs that arrived after the turn started (mid-pass, or starting a
201    /// pass that joined).
202    pub inputs: u32,
203    /// Passes whose `result` figures are in the sums below. The pane adds
204    /// its live count for the running pass only while that pass isn't
205    /// counted yet, so a pass is never counted twice.
206    pub counted_passes: u32,
207    /// Summed over the counted passes' `result` frames.
208    #[ts(type = "number")]
209    pub output_tokens: u64,
210    pub cost_usd: f64,
211    /// Model calls (`result.num_turns`, which counts steps, not turns).
212    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    /// Between passes, while a next pass is expected: the turn is over if
219    /// none starts by then.
220    #[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    /// Whether a pass starting at `now` may still join this turn as a
247    /// continuation: no pass running, not ended, and inside the settle window.
248    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    /// When the turn ended: its recorded end, or its last pass's end once
253    /// it is done settling with no next pass (nothing writes the end then).
254    /// `None` while a pass runs or a next one is expected.
255    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    /// Record the end of a turn that is over by `ended_at` but has no end
261    /// written yet: one whose settle window ran out.
262    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/// One finished pass's figures, from its `result` frame.
272#[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    /// Read from a Claude `result` frame; absent fields count as zero.
282    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/// How a pass is starting, for the ledger's join decision (§4.3).
294#[derive(Clone, Copy, Debug, PartialEq, Eq)]
295enum PassStart {
296    /// Input written by srv now: the pane, a jekt, a broadcast …
297    Input { origin: TurnOrigin, joins_turn: Option<u64> },
298    /// Input srv had queued (a spawn's backlog, a retry), or a spawn.
299    Queued,
300    /// The CLI started the pass itself, after a task notification.
301    CliWake,
302}
303
304/// The `agentturn` event's payload (`mps_events::AgentTurn`): the ledger, and its pane.
305#[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
313/// Called with the ledger after every change, outside the tracker's lock.
314pub type TurnLedgerPublisher = Arc<dyn Fn(&TurnLedger) + Send + Sync>;
315
316/// A publisher that sends the ledger as the persisted `agentturn` event on
317/// `block:<id>`, so a pane mounting mid-turn gets it at once.
318///
319/// A settling ledger is also published again, closed, when its window lapses
320/// with nothing newer published in between (`closed_when_lapsed`). The
321/// tracker closes it only lazily, at the next pass, and a consumer like the
322/// notification router needs to hear that the turn is over.
323pub 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
345/// The change from one published ledger to the next, in words, for the
346/// `[turn]` log: `None` when nothing worth a line changed (stats, `seq`).
347pub 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
370/// A settling ledger as it reads once its window lapsed with no next pass:
371/// ended at its last pass. `None` for a ledger that isn't settling. Same `seq`:
372/// it is the same state, only now known to be over.
373pub 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/// What started the current turn, and whether anything else got in.
384#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
385pub struct TurnProvenance {
386    pub origin: TurnOrigin,
387    /// Non-user input was delivered into this turn after it started (§6.3:
388    /// a taint, never undone within the turn).
389    pub tainted: bool,
390    /// The user's message that started the turn (origin `User` only).
391    #[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    /// What started the CURRENT turn. Cleared on every idle→active flip that
411    /// isn't labelled, so a turn only ever carries a provenance its own start
412    /// reported — a stale label can never leak into a later turn.
413    provenance: Option<TurnProvenance>,
414    /// Told ahead of a turn that starts later — a message queued while the
415    /// process spawns. Taken by the next idle→active flip.
416    next_turn: Option<TurnProvenance>,
417    /// The user-facing turn this block is in, or last was in.
418    ledger: Option<TurnLedger>,
419    /// Input was written while the current pass ran: the CLI may answer it
420    /// in a pass of its own straight after this one (J2).
421    input_during_pass: bool,
422    /// The CLI reported a finished background task, so it will start a pass
423    /// by itself to tell the model (J4). Cleared when any pass starts.
424    cli_wake_pending: bool,
425    /// Input reached the CLI during the pass that just ended, which it may
426    /// answer in a pass of its own straight after (J2). Good only while that
427    /// turn settles: a later pass the CLI starts is something else's.
428    continuation_expected: bool,
429    /// What started that pass: a continuation answers the same input, so it
430    /// carries the same provenance (self-quit gate, §6.3).
431    continuation_provenance: Option<TurnProvenance>,
432    /// The turn a held message being sent now should join, and when srv was
433    /// told: for controllers whose pass starts carry no input (`hint_join`).
434    pending_join: Option<(u64, u64)>,
435    /// The last `TurnLedger::seq` stamped, across turns.
436    ledger_seq: u64,
437    /// The trigger of a message queued for the next turn (`hint_next_turn`).
438    next_turn_trigger: Option<TurnTrigger>,
439    /// The task the CLI last reported finished, for the pass it wakes for.
440    pending_task_trigger: Option<TurnTrigger>,
441}
442
443impl TurnActivityTrackerInner {
444    /// The ledger to publish after a change: stamped with the next `seq` when
445    /// it differs from `before`.
446    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    /// idle→active with no label: take the queued hint, if any.
457    fn start_unlabelled(&mut self) {
458        self.provenance = self.next_turn.take();
459    }
460
461    /// Mid-pass input: the turn will expect a next pass when this one ends.
462    fn note_input_during_pass(&mut self, trigger: Option<TurnTrigger>) {
463        self.input_during_pass = true;
464        // A join hint for a message that reached a running pass is spent: it
465        // must not make the next fresh message join this turn.
466        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    /// A pass starts (idle→active): join the current turn or open a new one
474    /// (turn-model spec §4.3).
475    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        // A join hint is good once, and only briefly: it names a message being
481        // sent right now.
482        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        // A task that lost the race to this pass is not what a later pass
493        // answers: only the CLI's own wake for it may carry it (#4503).
494        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                // A fresh message the user typed after the agent stopped is a
505                // new turn, even inside the settle window (D3); only the
506                // pane's flush of a message held during this turn joins it.
507                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                // New input joined: a message, or a finished task the CLI woke
516                // for (#4503). A plain continuation or a queue drain brings none.
517                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            // A pass the CLI started with no task to name has no trigger: never
536            // a sender made up from its origin (#4503).
537            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    /// The running pass ended (active→idle). The turn waits for a next pass
558    /// when one is expected, and is over otherwise.
559    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
576/// Per-block turn-activity tracker.
577pub struct TurnActivityTracker {
578    block_id: String,
579    inner: Mutex<TurnActivityTrackerInner>,
580    publisher: Mutex<Option<TurnLedgerPublisher>>,
581    /// Taken before `inner` is released and held through the publish, so
582    /// ledgers go out in `seq` order. The broker keeps the last one for
583    /// replay, so an older one published last would be what a new subscriber
584    /// gets (#4492). Lock order: `inner` → `publish_order`.
585    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    /// A tracker that publishes its turn ledger on `broker`, when there is one.
595    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    /// Where ledger changes go (the `agentturn` event). Set once by the
628    /// controller that owns this tracker, when it has a broker.
629    pub fn set_ledger_publisher(&self, publisher: TurnLedgerPublisher) {
630        *self.publisher.lock().unwrap() = Some(publisher);
631    }
632
633    /// Release `inner` and publish the ledger if it changed, in `seq` order,
634    /// logging the change as a `[turn]` line (`muxlog`'s answer to "why did
635    /// my timer reset": turn-model spec §7).
636    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    /// Send the ledger to the publisher, if it changed. Called after the
661    /// tracker's lock is released.
662    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    /// Called when a new turn starts (subprocess spawned).
674    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    /// Atomically marks a turn active and reports whether one was already in
694    /// flight (the pre-call value) — a single lock acquisition, unlike
695    /// calling `is_active_turn()` then `set_active_turn(true)` separately.
696    /// That two-step form is a check-then-act race: `send_message` (user
697    /// input) and `send_user_message` (muxbus delivery) can run concurrently
698    /// on the same block, and both reading `false` before either writes
699    /// `true` lets both decide to spawn a watchdog — the exact duplicate the
700    /// "only re-arm when resuming from idle" logic exists to prevent.
701    pub fn mark_turn_active_returning_was_active(&self) -> bool {
702        self.mark_turn_active_from(None)
703    }
704
705    /// [`Self::mark_turn_active_returning_was_active`], saying who delivered
706    /// the input (turn provenance, SPEC_AGENT_SELF_QUIT §6.3):
707    /// - starting a turn records `input` as what started it — or, unlabelled,
708    ///   whatever was queued for it (else unknown);
709    /// - into a running turn, non-user input — or unlabelled input, which
710    ///   can't be proven to be the user — taints it.
711    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    /// The CLI reported a finished background task (`system/task_notification`).
754    /// It tells the model in a pass it starts itself: at once if it is idle,
755    /// straight after the running pass otherwise.
756    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    /// The CLI began a pass with no input from srv (`system/init` while idle).
763    /// Marked active only when something explains it (a task notification, or
764    /// input that reached it during the last pass), so a stray line can never
765    /// leave the agent reported busy with nothing to end it.
766    /// Returns whether the pass was marked (the caller then publishes status
767    /// and re-arms the heartbeat).
768    pub fn mark_turn_active_from_cli(&self) -> bool {
769        let now = (self.clock)();
770        let mut inner = self.inner.lock().unwrap();
771        // A continuation only while its turn still settles: after that, the
772        // input it would have answered is long dealt with (#4492).
773        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        // Answering input from the last pass: that pass's provenance (taint and
781        // all). A task woke it, alone or as well: automated, never the user, so
782        // the self-quit gate refuses in it, as for any automated turn.
783        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    /// The CLI's `result`: the running pass is over. Its figures (when the
798    /// frame carries them) go to the turn it belonged to in the same step as
799    /// the idle flip, so input that arrives right after can't take them.
800    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    /// Add a finished pass's `result` figures to the latest turn.
819    #[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    /// A message the pane held while turn `turn_id` ran is about to be sent
831    /// through a controller whose pass start carries no input (ACP, App Server,
832    /// one-shot subprocess): the pass it starts joins that turn, as
833    /// `TurnInput::joins_turn` does for the persistent controller (§4.3, J3).
834    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    /// Drop a join hint whose dispatch failed: no pass will consume it, and
840    /// a later message must not.
841    pub fn clear_join_hint(&self) {
842        self.inner.lock().unwrap().pending_join = None;
843    }
844
845    /// The current turn ledger, if any turn has started.
846    #[cfg(test)]
847    pub fn ledger(&self) -> Option<TurnLedger> {
848        self.inner.lock().unwrap().ledger.clone()
849    }
850
851    /// Called when the subprocess exits.
852    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    /// Whether there's an active turn in progress.
882    pub fn is_active_turn(&self) -> bool {
883        self.inner.lock().unwrap().active_turn
884    }
885
886    /// A message queued now will start a later turn (it waits for the
887    /// process to spawn). Two queued before that turn starts: the second, if
888    /// not the user's, taints it.
889    pub fn hint_next_turn(&self, input: TurnInput) {
890        let now = (self.clock)();
891        let mut inner = self.inner.lock().unwrap();
892        // A held message queued behind a spawn keeps its join for the pass the
893        // spawn starts (#4492).
894        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    /// An unlabelled message queued for the next turn: it can't be proven to
907    /// be the user, so a turn already labelled by an earlier queued message is
908    /// tainted. (Queued first, it leaves the turn unknown anyway.)
909    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    /// The turn is active because messages already accounted for are being
916    /// delivered — the queue drained after a spawn, a retry flush. Not new
917    /// input: an idle tracker starts the turn with whatever was queued for it,
918    /// and a running turn is left as it is (ReAgent P1 on #3789 — the plain
919    /// unlabelled mark tainted every freshly spawned user turn).
920    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    /// What started the turn in flight, if one is and it was reported.
938    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;