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}