Skip to main content

mlua_swarm/core/
state.rs

1//! `EngineState` — the single `Mutex`-guarded state object — plus the
2//! supporting types.
3//!
4//! `EngineState` holds every mutable piece of engine flow state (task
5//! table, session table, prompts, token records, worker handles, resume
6//! table, per-task notifiers, resources, per-attempt output events, and the
7//! event log tail). It sits on the Domain side of the Data / Domain split
8//! and is unchanged by the Data-plane (`output_store` module) refactor.
9
10use crate::types::{CapToken, Role, SessionId, StepId};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use std::collections::HashMap;
14use std::sync::Arc;
15use tokio::sync::{broadcast, Notify};
16
17// ─── Resume / Task ─────────────────────────────────────────────────────────
18
19/// Opaque handle identifying one `query_senior` suspend/`resume` cycle.
20/// Stored on `TaskState.suspended_on` and as the key of
21/// `EngineState.pending_resumes`.
22#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
23pub struct ResumeKey(pub String);
24
25impl ResumeKey {
26    /// Generate a random key (`RK-<12 hex bytes>`).
27    ///
28    /// The prefix moved from `R-` to `RK-` in issue #14: `R-` is reserved
29    /// for [`crate::types::RunId`], and sharing it would let a resume key
30    /// pass a run-id prefix check.
31    pub fn new() -> Self {
32        Self(format!("RK-{}", crate::types::uid_hex(12)))
33    }
34
35    /// Deterministic key for a Senior-escalation suspend on `task_id`
36    /// (`RK-senior-<task_id>`), so repeated escalations on the same task
37    /// are addressable without extra bookkeeping.
38    pub fn for_senior(task_id: &StepId) -> Self {
39        Self(format!("RK-senior-{}", task_id))
40    }
41}
42
43impl Default for ResumeKey {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49/// Lifecycle state of a task. `Pending` is the only non-terminal,
50/// non-`Suspended` state before the first `dispatch_attempt_with`;
51/// `Pass` / `Blocked` / `Cancelled` are terminal.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum TaskStatus {
55    /// Created via `start_task`, not yet dispatched.
56    Pending,
57    /// A `dispatch_attempt_with` call is in flight for this task.
58    Running,
59    /// Suspended awaiting a `query_senior`/`resume` round-trip.
60    Suspended,
61    /// The last attempt completed with `ok = true`.
62    Pass,
63    /// The last attempt completed with `ok = false` (or dispatch failed).
64    Blocked,
65    /// Cancelled via `cancel_task`.
66    Cancelled,
67}
68
69/// Static task definition supplied to `start_task`: which agent runs it
70/// and the initial prompt/directive text.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct TaskSpec {
73    /// Name of the agent that should execute this task.
74    pub agent: String,
75    /// Prompt/directive text seeded for attempt 1.
76    pub initial_directive: String,
77}
78
79/// The full mutable record of one task: its static `spec`, current
80/// `status`, attempt counter, and bookkeeping timestamps. Cloned out of
81/// `EngineState` on every read (e.g. by `read_task_state` / `poll_task`).
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct TaskState {
84    /// Unique task identifier (assigned by `start_task`).
85    pub id: StepId,
86    /// The static spec this task was created from.
87    pub spec: TaskSpec,
88    /// Current lifecycle status.
89    pub status: TaskStatus,
90    /// 1-based counter, bumped by `Engine::dispatch_attempt_with` each
91    /// time this task is dispatched.
92    pub attempt: u32,
93    /// Set while `status == Suspended`; the key needed to `resume` it.
94    pub suspended_on: Option<ResumeKey>,
95    /// Most recent result value posted via `post_result` or produced by a
96    /// completed attempt.
97    pub last_result: Option<Value>,
98    /// Unix timestamp (seconds) when the task was created.
99    pub created_at: u64,
100    /// Unix timestamp (seconds) of the last state mutation.
101    pub updated_at: u64,
102    /// Recursive swarm depth. The root (an Operator calling
103    /// `start_task`) is 0; a child spawned by a Worker calling
104    /// `start_task` is its parent's `depth + 1`. Exceeding
105    /// `EngineCfg.max_spawn_depth` raises `SpawnDepthExceeded`.
106    #[serde(default)]
107    pub spawn_depth: u32,
108}
109
110impl TaskState {
111    /// Construct a new `Pending` task with `attempt = 0` and
112    /// `spawn_depth = 0`; `created_at`/`updated_at` are set to now.
113    pub fn new(id: StepId, spec: TaskSpec) -> Self {
114        let now = crate::types::now_unix();
115        Self {
116            id,
117            spec,
118            status: TaskStatus::Pending,
119            attempt: 0,
120            suspended_on: None,
121            last_result: None,
122            created_at: now,
123            updated_at: now,
124            spawn_depth: 0,
125        }
126    }
127}
128
129/// Result of a `dispatch_attempt_with` call (or the conceptual outcome of
130/// a task attempt more broadly).
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub enum DispatchOutcome {
133    /// The attempt completed with `ok = true`; carries the result value.
134    Pass(Value),
135    /// The attempt completed with `ok = false`, or dispatch itself failed;
136    /// carries the result/error value.
137    Blocked(Value),
138    /// The task suspended (e.g. via `query_senior`) before completing;
139    /// carries the key needed to `resume` it.
140    Suspended(ResumeKey),
141    /// The task was cancelled before completing.
142    Cancelled,
143    /// The attempt did not complete within the allotted time.
144    Timeout,
145}
146
147// ─── Session ───────────────────────────────────────────────────────────────
148
149/// Persisted record of one attached Operator session: identity, role,
150/// heartbeat bookkeeping, owned tasks, and the `OperatorKind` cascade
151/// inputs plus registry IDs used to rebuild `OperatorInfo` on dispatch
152/// (see `Engine::resolve_operator_info`).
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct OperatorSession {
155    /// Unique session identifier (distinct from the token nonce).
156    pub id: SessionId,
157    /// Caller-supplied name identifying the Operator (not necessarily
158    /// unique across sessions).
159    pub operator_id: String,
160    /// Role the session's token was minted with.
161    pub role: Role,
162    /// Unix timestamp (seconds) when the session was attached.
163    pub attached_at: u64,
164    /// Unix timestamp (seconds) of the last heartbeat/attach touch.
165    pub last_seen: u64,
166    /// Whether the session is currently considered live. Flipped to
167    /// `false` by `detach` or by `start_detach_loop` on a heartbeat miss.
168    pub attached: bool,
169    /// Task IDs started by this session (via `start_task` while this
170    /// session's token was current).
171    pub owned_task_ids: Vec<StepId>,
172    /// Fingerprint (`CapToken::fingerprint`, SHA-256 of the nonce) of the
173    /// `CapToken` this session was attached with; used to look sessions up
174    /// by token in `with_state` closures. Holds the fingerprint rather
175    /// than the nonce so the session table carries no secret material
176    /// (issue #14).
177    pub token_fp: String,
178    /// The Operator's `kind`, plus IDs of
179    /// the `SeniorBridge` / `SpawnHook` registered on the engine's
180    /// `BridgeRegistry`. Persisted (all `String`; no `Arc<dyn ...>`). At
181    /// `dispatch_attempt` time the engine looks these up in the registry
182    /// and builds an `OperatorInfo` to inject into `Ctx`.
183    ///
184    /// # 4-tier `OperatorKind` cascade — "Runtime Global" tier
185    ///
186    /// This field is the literal value passed to `Engine::attach_with_ids`'s
187    /// `kind` parameter, and is fed to `crate::core::ctx::collapse_operator_kind`
188    /// as the `runtime_global` tier verbatim: `Some(_)` is always an
189    /// explicit Runtime Global request that outranks both BP tiers — even
190    /// `Some(OperatorKind::Automate)` — and `None` means "not requested",
191    /// letting the BP-level tiers (`bp_agent_kinds` / `bp_global_kind`) take
192    /// over. `#[serde(default)]` keeps existing persisted sessions (from
193    /// before this field existed / was `Option`) deserializing as `None`.
194    /// See `crate::core::ctx::collapse_operator_kind` for the full cascade +
195    /// rationale.
196    #[serde(default)]
197    pub operator_kind: Option<crate::core::ctx::OperatorKind>,
198    /// "Runtime Agent-level" tier (highest priority) of the `OperatorKind`
199    /// cascade — per-agent override supplied at task-launch time via
200    /// `TaskLaunchInput.operator_kind_overrides` / `TaskApplicationInput
201    /// .operator_kind_overrides`. Keyed by `AgentDef.name`.
202    #[serde(default)]
203    pub runtime_agent_kinds: HashMap<String, crate::core::ctx::OperatorKind>,
204    /// "BP Agent-level" tier of the `OperatorKind` cascade — baked at
205    /// `TaskLaunchService::launch` time from `Blueprint.operators[].kind`,
206    /// resolved per-agent via `AgentDef.spec.operator_ref`. Keyed by
207    /// `AgentDef.name` (not `OperatorDef.name`).
208    #[serde(default)]
209    pub bp_agent_kinds: HashMap<String, crate::core::ctx::OperatorKind>,
210    /// "BP Global" tier of the `OperatorKind` cascade — baked at
211    /// `TaskLaunchService::launch` time from `Blueprint.default_operator_kind`.
212    #[serde(default)]
213    pub bp_global_kind: Option<crate::core::ctx::OperatorKind>,
214    /// ID of the `Arc<dyn SeniorBridge>` registered on the engine's
215    /// `BridgeRegistry`, if any; resolved back into `OperatorInfo.senior_bridge`.
216    #[serde(default)]
217    pub bridge_id: Option<String>,
218    /// ID of the `Arc<dyn SpawnHook>` registered on the engine's
219    /// `BridgeRegistry`, if any; resolved back into `OperatorInfo.spawn_hook`.
220    #[serde(default)]
221    pub hook_id: Option<String>,
222    /// ID of the `Arc<dyn Operator>` registered on the `OperatorRegistry`.
223    /// Used by `OperatorDelegateMiddleware` when `kind = MainAi` /
224    /// `Composite` and `operator_id` is `Some`: it delegates the entire
225    /// spawn to `operator.execute`.
226    #[serde(default)]
227    pub operator_backend_id: Option<String>,
228}
229
230// ─── Token record (= server-side counter holder) ──────────────────────────
231
232/// Server-side counter/state holder paired 1:1 with a minted `CapToken`
233/// (keyed by nonce in `EngineState.tokens`). Tracks remaining uses,
234/// revocation, and — for Worker tokens — the task the token is bound to.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct CapTokenRecord {
237    /// The token this record backs.
238    pub token: CapToken,
239    /// Remaining number of verb-consuming calls. `None` means unlimited
240    /// (session-style tokens); `Some(0)` makes `consume` fail.
241    pub uses_left: Option<u32>, // None = unlimited (session)
242    /// When `true`, `consume` always fails regardless of `uses_left`.
243    pub revoked: bool,
244    /// The task this Worker token is bound to (set when minted via
245    /// `dispatch_attempt`). Used on two axes:
246    ///   1. **Depth tracking.** When a Worker calls `start_task` to spawn a
247    ///      child, the child receives this task's `spawn_depth + 1`.
248    ///   2. **Ownership gate.** When a Worker calls a state-touch verb
249    ///      (`fetch_prompt` / `post_result` / `read_task_state` /
250    ///      `cancel_task` / `poll_task`), the argument's `task_id` must
251    ///      match this value. `start_task`
252    ///      and `dispatch_attempt` are exempt — recursive swarming must
253    ///      stay open, and depth is capped by `max_spawn_depth`.
254    ///
255    ///      Operator tokens (minted at attach time) leave this `None`, so
256    ///      they can touch any task.
257    #[serde(default)]
258    pub task_id: Option<StepId>,
259}
260
261impl CapTokenRecord {
262    /// Wrap a freshly minted `CapToken` with no bound task (`task_id =
263    /// None`) — the shape used for Operator/session tokens.
264    pub fn from_token(token: CapToken) -> Self {
265        Self {
266            uses_left: token.max_uses,
267            token,
268            revoked: false,
269            task_id: None,
270        }
271    }
272
273    /// Convenience constructor used when minting a Worker token — binds
274    /// the record to the target task.
275    pub fn from_worker_token(token: CapToken, task_id: StepId) -> Self {
276        Self {
277            uses_left: token.max_uses,
278            token,
279            revoked: false,
280            task_id: Some(task_id),
281        }
282    }
283
284    /// Consume one use. `None` (session token) always returns `Ok`;
285    /// `Some(0)` returns `Err`.
286    pub fn consume(&mut self) -> Result<(), CapTokenConsumeError> {
287        if self.revoked {
288            return Err(CapTokenConsumeError::Revoked);
289        }
290        match self.uses_left.as_mut() {
291            None => Ok(()),
292            Some(0) => Err(CapTokenConsumeError::Exhausted),
293            Some(n) => {
294                *n -= 1;
295                Ok(())
296            }
297        }
298    }
299}
300
301/// Why [`CapTokenRecord::consume`] refused to spend a use.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
303pub enum CapTokenConsumeError {
304    /// The record was explicitly revoked (`revoked = true`); revocation
305    /// is permanent and independent of `uses_left`.
306    #[error("token revoked")]
307    Revoked,
308    /// The record's `uses_left` budget (`Some(0)`) is spent.
309    #[error("token uses exhausted")]
310    Exhausted,
311}
312
313// ─── Event ─────────────────────────────────────────────────────────────────
314
315/// Engine lifecycle event. Every event is both appended to
316/// `EngineState.event_log_tail` (in-process ring buffer) and broadcast on
317/// `Engine::event_tx` for live subscribers.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319#[serde(tag = "kind", rename_all = "snake_case")]
320pub enum Event {
321    /// A session was attached (`attach` / `attach_with` / `attach_with_ids`).
322    SessionAttached {
323        /// The newly attached session.
324        session_id: SessionId,
325        /// Role its token was minted with.
326        role: Role,
327    },
328    /// A session was detached (`detach`, or a heartbeat-miss timeout).
329    SessionDetached {
330        /// The session that was detached.
331        session_id: SessionId,
332    },
333    /// A new task was created via `start_task`.
334    TaskCreated {
335        /// The newly created task.
336        task_id: StepId,
337    },
338    /// An attempt began dispatching (not currently emitted by
339    /// `dispatch_attempt_with`; reserved for future use).
340    TaskAttemptStarted {
341        /// The task being dispatched.
342        task_id: StepId,
343        /// The attempt number.
344        attempt: u32,
345    },
346    /// An attempt finished, Pass or Blocked, with the resulting value.
347    TaskAttemptCompleted {
348        /// The task whose attempt completed.
349        task_id: StepId,
350        /// The attempt number that completed.
351        attempt: u32,
352        /// The result value produced by the attempt.
353        result: Value,
354    },
355    /// The task attempt completed with `ok = true`.
356    TaskPass {
357        /// The task that passed.
358        task_id: StepId,
359        /// The result value.
360        result: Value,
361    },
362    /// The task attempt completed with `ok = false`.
363    TaskBlocked {
364        /// The task that was blocked.
365        task_id: StepId,
366        /// The result/error value.
367        result: Value,
368    },
369    /// A worker appended an `OutputEvent` via `submit_output`.
370    WorkerOutput {
371        /// The task the output belongs to.
372        task_id: StepId,
373        /// The attempt the output belongs to.
374        attempt: u32,
375        /// The appended output event.
376        event: crate::worker::output::OutputEvent,
377    },
378    /// The task suspended pending a `resume` for `key`.
379    TaskSuspended {
380        /// The suspended task.
381        task_id: StepId,
382        /// The key needed to `resume` it.
383        key: ResumeKey,
384    },
385    /// The task resumed after `resume(key, ..)` was called.
386    TaskResumed {
387        /// The resumed task.
388        task_id: StepId,
389        /// The key that was resumed.
390        key: ResumeKey,
391    },
392    /// The task was cancelled via `cancel_task`.
393    TaskCancelled {
394        /// The cancelled task.
395        task_id: StepId,
396    },
397    /// `query_senior` was called, asking `question` on behalf of `task_id`.
398    SeniorQueried {
399        /// The task that triggered the query.
400        task_id: StepId,
401        /// The question posed to the Senior.
402        question: Value,
403    },
404    /// A Senior's `answer` was stored via `resume`.
405    SeniorAnswered {
406        /// The task the answer applies to.
407        task_id: StepId,
408        /// The Senior's answer.
409        answer: Value,
410    },
411}
412
413/// Receiver half of the engine-wide `Event` broadcast channel, obtained
414/// via `Engine::subscribe`.
415pub type EventStream = broadcast::Receiver<Event>;
416
417// ─── Resume pending (= Notify-based wait + stored answer) ─────────────────
418
419/// Entry for a task suspended via `query_senior`, waiting to be resumed.
420///
421/// The `Notify` + `answer: Option<Value>` form (rather than a oneshot
422/// channel) is deliberate: the answer stays inside `EngineState` even if
423/// the caller (an Operator) **detaches and reattaches**, so it can pull
424/// the answer out via `await_resume` after reattach.
425#[derive(Debug, Clone)]
426pub struct ResumePending {
427    /// Wakes any `await_resume` waiter once `answer` is set.
428    pub notify: Arc<Notify>,
429    /// The stored answer, once `resume` has been called for this key.
430    pub answer: Option<Value>,
431}
432
433impl ResumePending {
434    /// Create an unanswered pending entry (fresh `Notify`, `answer = None`).
435    pub fn new() -> Self {
436        Self {
437            notify: Arc::new(Notify::new()),
438            answer: None,
439        }
440    }
441}
442
443impl Default for ResumePending {
444    fn default() -> Self {
445        Self::new()
446    }
447}
448
449// ─── EngineState (= the locked thing) ──────────────────────────────────────
450
451/// The single `Mutex`-guarded blob of engine flow state, accessed only
452/// through `Engine::with_state` (see the R1-R4 discipline documented
453/// there).
454#[derive(Debug)]
455pub struct EngineState {
456    /// All known tasks, keyed by `StepId`.
457    pub tasks: HashMap<StepId, TaskState>,
458    /// All attached/detached sessions, keyed by `SessionId`.
459    pub sessions: HashMap<SessionId, OperatorSession>,
460    /// Per-`(task_id, attempt)` prompt/directive text, seeded from
461    /// `TaskSpec.initial_directive` and fetched via `fetch_prompt`.
462    pub prompts: HashMap<(StepId, u32), String>,
463    /// Per-attempt `system_prompt`: `AgentDef.profile.system_prompt` is
464    /// baked at compile time, rendered inside `OperatorSpawner::spawn`,
465    /// and stashed here for the SubAgent to fetch alongside its prompt via
466    /// `HTTP /v1/worker/prompt`. The value is `Option<String>` so a missing
467    /// profile can be distinguished: an absent key means "not yet baked",
468    /// while `Some(None)` means "baked and profile is explicitly absent".
469    pub systems: HashMap<(StepId, u32), Option<String>>,
470    /// All minted `CapToken` records, keyed by token fingerprint
471    /// (`CapToken::fingerprint` = SHA-256 of the nonce; issue #14 — the
472    /// key is loggable, the nonce is not).
473    pub tokens: HashMap<String, CapTokenRecord>, // key = token fingerprint
474    /// Short worker handle (`wh-XXXXXXXX`, 12 chars) → token-fingerprint
475    /// lookup map. Resolves the `worker_handle` field a SubAgent receives
476    /// with its prompt. There is no signature verification: `task_id` is
477    /// resolved by a plain `HashMap` lookup — deliberately thin for the
478    /// local running over WebSocket, and adopted specifically to remove
479    /// the base64 copy-paste failure mode.
480    pub worker_handles: HashMap<String, String>,
481    /// Outstanding `query_senior` suspensions awaiting `resume`.
482    pub pending_resumes: HashMap<ResumeKey, ResumePending>,
483    /// Per-task notifier — `notify_waiters` fires on every task-status
484    /// change. Used by `poll_task` on the caller side, and by callers that
485    /// need to `await` again after detach/reattach.
486    pub task_notifies: HashMap<StepId, Arc<Notify>>,
487    /// Arbitrary named resources set via `set_resource` and read via
488    /// `fetch_data`.
489    pub resources: HashMap<String, Value>,
490    /// Per-attempt output-event log. The `SpawnerAdapter` appends via
491    /// `submit_output`; the dispatch path pulls the terminal
492    /// `OutputEvent::Final` off the tail and decides Pass / Blocked.
493    pub output_store: HashMap<(StepId, u32), Vec<crate::worker::output::OutputEvent>>,
494    /// Bounded in-process tail of recent `Event`s (most recent last),
495    /// trimmed to `event_log_max` by `push_event`.
496    pub event_log_tail: Vec<Event>,
497    /// Maximum length of `event_log_tail` before older entries are
498    /// dropped.
499    pub event_log_max: usize,
500}
501
502impl EngineState {
503    /// Construct an empty `EngineState` with `event_log_max = 1024`.
504    pub fn new() -> Self {
505        Self {
506            tasks: HashMap::new(),
507            sessions: HashMap::new(),
508            prompts: HashMap::new(),
509            systems: HashMap::new(),
510            tokens: HashMap::new(),
511            worker_handles: HashMap::new(),
512            pending_resumes: HashMap::new(),
513            task_notifies: HashMap::new(),
514            resources: HashMap::new(),
515            output_store: HashMap::new(),
516            event_log_tail: Vec::new(),
517            event_log_max: 1024,
518        }
519    }
520
521    /// Ensure a per-task `Notify` exists; return the existing one if any.
522    pub fn ensure_task_notify(&mut self, task_id: &StepId) -> Arc<Notify> {
523        self.task_notifies
524            .entry(task_id.clone())
525            .or_insert_with(|| Arc::new(Notify::new()))
526            .clone()
527    }
528
529    /// Append `ev` to `event_log_tail`, trimming the oldest entries once
530    /// `event_log_max` is exceeded.
531    pub fn push_event(&mut self, ev: Event) {
532        self.event_log_tail.push(ev);
533        if self.event_log_tail.len() > self.event_log_max {
534            let overflow = self.event_log_tail.len() - self.event_log_max;
535            self.event_log_tail.drain(..overflow);
536        }
537    }
538}
539
540impl Default for EngineState {
541    fn default() -> Self {
542        Self::new()
543    }
544}