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 value.
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 value seeded for attempt 1. Passed through
76 /// verbatim from the evaluated `Step.in` (issue #18 — no premature
77 /// `Value → String` coercion at this layer). Consumers that need a
78 /// rendered `String` (the `EngineState.prompts` table feeding the
79 /// Worker HTTP path, and the WS `Spawn.directive` reminder text)
80 /// render it at their own late boundary; strings pass through
81 /// verbatim, anything else is serde-stringified.
82 pub initial_directive: Value,
83 /// GH #21 Phase 2 — the Step tier's resolved context bundle, threaded
84 /// through from `EngineDispatcher::dispatch`'s `$step_meta` envelope
85 /// resolution (`None` when the dispatched `Step.in` carried no
86 /// envelope — pre-#21-Phase-2 Blueprints unaffected). Re-read from
87 /// the spec on EVERY `Engine::dispatch_attempt_with` attempt (not
88 /// cached once), so retries and Run-rekicks all carry it; inserted
89 /// into `Ctx.meta.runtime[STEP_CTX_KEY]`
90 /// (`crate::core::agent_context::STEP_CTX_KEY`), consumed by
91 /// `crate::middleware::agent_context::AgentContextMiddleware`.
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub step_ctx: Option<Value>,
94 /// Per-run override for
95 /// [`crate::core::config::CheckPolicy`] — governs how submit-time
96 /// projection sinks (`Engine::materialize_final_submission` /
97 /// `Engine::materialize_artifact_submission`) react to fail-open
98 /// conditions for THIS task. `None` (the default; backward-compat
99 /// with pre-`CheckPolicy` `TaskSpec`s deserialised without the
100 /// field) falls back to `EngineCfg.check_policy` (server-wide
101 /// default). `Some(policy)` overrides for this task only — see the
102 /// `CheckPolicy` doc for the semantics of the three modes.
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub check_policy: Option<crate::core::config::CheckPolicy>,
105}
106
107/// The full mutable record of one task: its static `spec`, current
108/// `status`, attempt counter, and bookkeeping timestamps. Cloned out of
109/// `EngineState` on every read (e.g. by `read_task_state` / `poll_task`).
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct TaskState {
112 /// Unique task identifier (assigned by `start_task`).
113 pub id: StepId,
114 /// The static spec this task was created from.
115 pub spec: TaskSpec,
116 /// Current lifecycle status.
117 pub status: TaskStatus,
118 /// 1-based counter, bumped by `Engine::dispatch_attempt_with` each
119 /// time this task is dispatched.
120 pub attempt: u32,
121 /// Set while `status == Suspended`; the key needed to `resume` it.
122 pub suspended_on: Option<ResumeKey>,
123 /// Most recent result value posted via `post_result` or produced by a
124 /// completed attempt.
125 pub last_result: Option<Value>,
126 /// Unix timestamp (seconds) when the task was created.
127 pub created_at: u64,
128 /// Unix timestamp (seconds) of the last state mutation.
129 pub updated_at: u64,
130 /// Recursive swarm depth. The root (an Operator calling
131 /// `start_task`) is 0; a child spawned by a Worker calling
132 /// `start_task` is its parent's `depth + 1`. Exceeding
133 /// `EngineCfg.max_spawn_depth` raises `SpawnDepthExceeded`.
134 #[serde(default)]
135 pub spawn_depth: u32,
136}
137
138impl TaskState {
139 /// Construct a new `Pending` task with `attempt = 0` and
140 /// `spawn_depth = 0`; `created_at`/`updated_at` are set to now.
141 pub fn new(id: StepId, spec: TaskSpec) -> Self {
142 let now = crate::types::now_unix();
143 Self {
144 id,
145 spec,
146 status: TaskStatus::Pending,
147 attempt: 0,
148 suspended_on: None,
149 last_result: None,
150 created_at: now,
151 updated_at: now,
152 spawn_depth: 0,
153 }
154 }
155}
156
157/// Reserved sentinel key used by [`wrap_skip_marker`] / [`is_skip_marker`]
158/// to encode a [`DispatchOutcome::Skip`] payload as a plain
159/// `serde_json::Value` (GH #76 Skip tier: Skip tier). Documented as a reserved
160/// key on the wire: an ordinary worker payload MUST NOT contain a
161/// top-level object field named `"__mse_skip"`.
162pub const SKIP_MARKER_KEY: &str = "__mse_skip";
163
164/// Wrap `v` in the reserved `{ "__mse_skip": true, "value": v }` sentinel
165/// shape produced by a [`SubmitOutcome::Skip`] submission and consumed by
166/// [`is_skip_marker`] / [`unwrap_skip_marker`] on the read side. See
167/// [`SKIP_MARKER_KEY`] for the reserved-key contract.
168pub fn wrap_skip_marker(v: Value) -> Value {
169 let mut map = serde_json::Map::new();
170 map.insert(SKIP_MARKER_KEY.to_string(), Value::Bool(true));
171 map.insert("value".to_string(), v);
172 Value::Object(map)
173}
174
175/// Return `true` when `v` is an object with `{ "__mse_skip": true }` set —
176/// the sentinel shape [`wrap_skip_marker`] produces. Used by the
177/// dispatcher (and the flow-ir binding boundary in future subtasks) to
178/// route a Skip completion out of the ordinary Pass/Blocked value path.
179pub fn is_skip_marker(v: &Value) -> bool {
180 v.as_object()
181 .and_then(|m| m.get(SKIP_MARKER_KEY))
182 .and_then(|b| b.as_bool())
183 .unwrap_or(false)
184}
185
186/// If `v` is a skip-marker sentinel (see [`is_skip_marker`]), return the
187/// carried inner value cloned out of the `"value"` field (or `Value::Null`
188/// when the field is absent — a malformed sentinel is still a skip signal
189/// with no payload). Returns `None` otherwise, so the caller can fall back
190/// to the ordinary Pass/Blocked path.
191pub fn unwrap_skip_marker(v: &Value) -> Option<Value> {
192 if !is_skip_marker(v) {
193 return None;
194 }
195 Some(
196 v.as_object()
197 .and_then(|m| m.get("value"))
198 .cloned()
199 .unwrap_or(Value::Null),
200 )
201}
202
203/// Result of a `dispatch_attempt_with` call (or the conceptual outcome of
204/// a task attempt more broadly).
205///
206/// `#[non_exhaustive]` (GH #76 Skip tier) so future tier additions (e.g. a
207/// `Deferred` sibling of `Skip`) are additive — external crates cannot
208/// exhaustively match on this enum, and must include a `_` arm.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210#[non_exhaustive]
211pub enum DispatchOutcome {
212 /// The attempt completed with `ok = true`; carries the result value.
213 Pass(Value),
214 /// The attempt completed with `ok = false`, or dispatch itself failed;
215 /// carries the result/error value.
216 Blocked(Value),
217 /// GH #76 Skip tier: sibling tier of [`Self::Pass`] / [`Self::Blocked`]. The
218 /// worker completed successfully (`ok = true`) but declared its
219 /// output is NOT applicable to the surrounding flow — the enclosing
220 /// [`crate::blueprint::EngineDispatcher::dispatch`] treats this as
221 /// flow-continuation (does NOT propagate an error to flow-ir) while
222 /// short-circuiting the write to the step's declared `out` binding.
223 /// Carries the returning agent's verdict payload for observability
224 /// (`StepEntry.status = "skipped"`); downstream `$.<step_id>`
225 /// references see whatever pre-existing value the binding held
226 /// (typically absent). Mirrors `spawn_halt`'s "ok=true + marker" wire
227 /// pattern.
228 Skip(Value),
229 /// The task suspended (e.g. via `query_senior`) before completing;
230 /// carries the key needed to `resume` it.
231 Suspended(ResumeKey),
232 /// The task was cancelled before completing.
233 Cancelled,
234 /// The attempt did not complete within the allotted time.
235 Timeout,
236}
237
238/// GH #76 Skip tier: the completion tier a caller of
239/// [`crate::core::engine::Engine::submit_worker_result_trusted`] signals
240/// alongside its `value`. Sibling to `DispatchOutcome` (the engine-side
241/// outcome enum) — this one is the CALLER's intent enum, whereas
242/// [`DispatchOutcome`] is the reduced outcome the engine derives from the
243/// completed attempt.
244///
245/// Mapping into the wire shape stored in `EngineState.output_store`'s
246/// terminal `OutputEvent::Final`:
247///
248/// | outcome | `Final.ok` | `Final.content` |
249/// |----------|------------|---------------------------------------------------|
250/// | `Pass` | `true` | `value` verbatim |
251/// | `Blocked`| `false` | `value` verbatim |
252/// | `Skip` | `true` | [`wrap_skip_marker(value)`](wrap_skip_marker) |
253///
254/// `#[non_exhaustive]` so future tier additions stay additive.
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256#[non_exhaustive]
257pub enum SubmitOutcome {
258 /// Ordinary success — the worker's output is the step's value.
259 Pass,
260 /// Ordinary failure — the worker itself declared `ok = false`. Maps
261 /// to `DispatchOutcome::Blocked` at dispatch time; the
262 /// verdict-contract completion check is exempt (same "ok=false is
263 /// exempt" rule the pre-Skip world already applied).
264 Blocked,
265 /// Skip tier (GH #76): ok=true for flow-continuation purposes but the
266 /// value is wrapped in the skip-marker sentinel so the dispatcher can
267 /// route it to `DispatchOutcome::Skip` and downstream binding-write
268 /// paths can short-circuit. The verdict-contract completion check is
269 /// intentionally skipped — a Skip is the agent declaring "not
270 /// applicable", not a real verdict value.
271 Skip,
272}
273
274// ─── Session ───────────────────────────────────────────────────────────────
275
276/// Everything one launch bakes for its own dispatches: identity, role,
277/// heartbeat bookkeeping, owned tasks, and the `OperatorKind` cascade
278/// inputs plus registry IDs used to rebuild `OperatorInfo` on dispatch
279/// (see `Engine::resolve_operator_info`).
280///
281/// # Why this is an envelope and not a session
282///
283/// It was called `OperatorSession`, and the name said the wrong thing on
284/// both halves. It is not *the* Operator session — that is
285/// `WSOperatorSession`, one per live WS connection, which outlives any
286/// number of launches and is reachable by sid. This is minted by
287/// `Engine::attach*` once per launch, keyed by a `SessionId` nobody
288/// dispatches to, and read only to rebuild the launch's own `Ctx`. Two
289/// launches driven by the same operator have two of these; a handover
290/// changes neither.
291///
292/// So the name now says what it holds: the launch-time envelope its
293/// dispatches are opened from. The `SessionId` in [`Self::id`] stays — it
294/// is the engine's own token/session bookkeeping key, and renaming that
295/// axis is a different change from renaming this type.
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct LaunchEnvelope {
298 /// Unique session identifier (distinct from the token nonce).
299 pub id: SessionId,
300 /// Caller-supplied name identifying the Operator (not necessarily
301 /// unique across sessions).
302 pub operator_id: String,
303 /// Role the session's token was minted with.
304 pub role: Role,
305 /// Unix timestamp (seconds) when the session was attached.
306 pub attached_at: u64,
307 /// Unix timestamp (seconds) of the last heartbeat/attach touch.
308 pub last_seen: u64,
309 /// Whether the session is currently considered live. Flipped to
310 /// `false` by `detach` or by `start_detach_loop` on a heartbeat miss.
311 pub attached: bool,
312 /// Task IDs started by this session (via `start_task` while this
313 /// session's token was current).
314 pub owned_task_ids: Vec<StepId>,
315 /// Fingerprint (`CapToken::fingerprint`, SHA-256 of the nonce) of the
316 /// `CapToken` this session was attached with; used to look sessions up
317 /// by token in `with_state` closures. Holds the fingerprint rather
318 /// than the nonce so the session table carries no secret material
319 /// (issue #14).
320 pub token_fp: String,
321 /// The Operator's `kind`, plus IDs of
322 /// the `SeniorBridge` / `SpawnHook` registered on the engine's
323 /// `BridgeRegistry`. Persisted (all `String`; no `Arc<dyn ...>`). At
324 /// `dispatch_attempt` time the engine looks these up in the registry
325 /// and builds an `OperatorInfo` to inject into `Ctx`.
326 ///
327 /// # 4-tier `OperatorKind` cascade — "Runtime Global" tier
328 ///
329 /// This field is the literal value passed to `Engine::attach_with_ids`'s
330 /// `kind` parameter, and is fed to `crate::core::ctx::collapse_operator_kind`
331 /// as the `runtime_global` tier verbatim: `Some(_)` is always an
332 /// explicit Runtime Global request that outranks both BP tiers — even
333 /// `Some(OperatorKind::Automate)` — and `None` means "not requested",
334 /// letting the BP-level tiers (`bp_agent_kinds` / `bp_global_kind`) take
335 /// over. `#[serde(default)]` keeps existing persisted sessions (from
336 /// before this field existed / was `Option`) deserializing as `None`.
337 /// See `crate::core::ctx::collapse_operator_kind` for the full cascade +
338 /// rationale.
339 #[serde(default)]
340 pub operator_kind: Option<crate::core::ctx::OperatorKind>,
341 /// "Runtime Agent-level" tier (highest priority) of the `OperatorKind`
342 /// cascade — per-agent override supplied at task-launch time via
343 /// `TaskLaunchInput.operator_kind_overrides` / `TaskApplicationInput
344 /// .operator_kind_overrides`. Keyed by `AgentDef.name`.
345 #[serde(default)]
346 pub runtime_agent_kinds: HashMap<String, crate::core::ctx::OperatorKind>,
347 /// "BP Agent-level" tier of the `OperatorKind` cascade — baked at
348 /// `TaskLaunchService::launch` time from `Blueprint.operators[].kind`,
349 /// resolved per-agent via `AgentDef.spec.operator_ref`. Keyed by
350 /// `AgentDef.name` (not `OperatorDef.name`).
351 #[serde(default)]
352 pub bp_agent_kinds: HashMap<String, crate::core::ctx::OperatorKind>,
353 /// "BP Global" tier of the `OperatorKind` cascade — baked at
354 /// `TaskLaunchService::launch` time from `Blueprint.default_operator_kind`.
355 #[serde(default)]
356 pub bp_global_kind: Option<crate::core::ctx::OperatorKind>,
357 /// ID of the `Arc<dyn SeniorBridge>` registered on the engine's
358 /// `BridgeRegistry`, if any; resolved back into `OperatorInfo.senior_bridge`.
359 #[serde(default)]
360 pub bridge_id: Option<String>,
361 /// ID of the `Arc<dyn SpawnHook>` registered on the engine's
362 /// `BridgeRegistry`, if any; resolved back into `OperatorInfo.spawn_hook`.
363 #[serde(default)]
364 pub hook_id: Option<String>,
365 /// ID of the `Arc<dyn Operator>` registered on the `OperatorRegistry`.
366 ///
367 /// **Nothing resolves this at dispatch any more.** It was
368 /// `OperatorDelegateMiddleware`'s input — the middleware looked the id
369 /// up per spawn and delegated the whole spawn to `operator.execute` —
370 /// and that layer was removed precisely because reading a launch-time
371 /// id meant the delegate axis could not follow a seat handover. What
372 /// the field still does is (a) travel in the persisted session blob,
373 /// whose shape old records are deserialized against, and (b) name the
374 /// key `Engine::list_operator_ids` validates a launch's `operator_sid`
375 /// against. A dispatch reaches its Operator through the agent's
376 /// declared seat and the Run's current holder, not through here.
377 ///
378 /// Its one source is the launch's `operator_sid`
379 /// (`TaskLaunchInput::operator_sid`), which is where the three former
380 /// spellings of this value were folded together. The name stays
381 /// registry-shaped because that is what the field *is* at this layer —
382 /// a key into `Engine.operators`, sibling to [`Self::bridge_id`] and
383 /// [`Self::hook_id`] — and because the key space it indexes is a
384 /// superset of the WS sids: an embedder can `register_operator` under
385 /// any name and launch against it.
386 #[serde(default)]
387 pub operator_backend_id: Option<String>,
388}
389
390// ─── Token record (= server-side counter holder) ──────────────────────────
391
392/// Server-side counter/state holder paired 1:1 with a minted `CapToken`
393/// (keyed by nonce in `EngineState.tokens`). Tracks remaining uses,
394/// revocation, and — for Worker tokens — the task the token is bound to.
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct CapTokenRecord {
397 /// The token this record backs.
398 pub token: CapToken,
399 /// Remaining number of verb-consuming calls. `None` means unlimited
400 /// (session-style tokens); `Some(0)` makes `consume` fail.
401 pub uses_left: Option<u32>, // None = unlimited (session)
402 /// When `true`, `consume` always fails regardless of `uses_left`.
403 pub revoked: bool,
404 /// The task this Worker token is bound to (set when minted via
405 /// `dispatch_attempt`). Used on two axes:
406 /// 1. **Depth tracking.** When a Worker calls `start_task` to spawn a
407 /// child, the child receives this task's `spawn_depth + 1`.
408 /// 2. **Ownership gate.** When a Worker calls a state-touch verb
409 /// (`fetch_prompt` / `post_result` / `read_task_state` /
410 /// `cancel_task` / `poll_task`), the argument's `task_id` must
411 /// match this value. `start_task`
412 /// and `dispatch_attempt` are exempt — recursive swarming must
413 /// stay open, and depth is capped by `max_spawn_depth`.
414 ///
415 /// Operator tokens (minted at attach time) leave this `None`, so
416 /// they can touch any task.
417 #[serde(default)]
418 pub task_id: Option<StepId>,
419}
420
421impl CapTokenRecord {
422 /// Wrap a freshly minted `CapToken` with no bound task (`task_id =
423 /// None`) — the shape used for Operator/session tokens.
424 pub fn from_token(token: CapToken) -> Self {
425 Self {
426 uses_left: token.max_uses,
427 token,
428 revoked: false,
429 task_id: None,
430 }
431 }
432
433 /// Convenience constructor used when minting a Worker token — binds
434 /// the record to the target task.
435 pub fn from_worker_token(token: CapToken, task_id: StepId) -> Self {
436 Self {
437 uses_left: token.max_uses,
438 token,
439 revoked: false,
440 task_id: Some(task_id),
441 }
442 }
443
444 /// Consume one use. `None` (session token) always returns `Ok`;
445 /// `Some(0)` returns `Err`.
446 pub fn consume(&mut self) -> Result<(), CapTokenConsumeError> {
447 if self.revoked {
448 return Err(CapTokenConsumeError::Revoked);
449 }
450 match self.uses_left.as_mut() {
451 None => Ok(()),
452 Some(0) => Err(CapTokenConsumeError::Exhausted),
453 Some(n) => {
454 *n -= 1;
455 Ok(())
456 }
457 }
458 }
459}
460
461/// Why [`CapTokenRecord::consume`] refused to spend a use.
462#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
463pub enum CapTokenConsumeError {
464 /// The record was explicitly revoked (`revoked = true`); revocation
465 /// is permanent and independent of `uses_left`.
466 #[error("token revoked")]
467 Revoked,
468 /// The record's `uses_left` budget (`Some(0)`) is spent.
469 #[error("token uses exhausted")]
470 Exhausted,
471}
472
473// ─── Event ─────────────────────────────────────────────────────────────────
474
475/// Engine lifecycle event. Every event is both appended to
476/// `EngineState.event_log_tail` (in-process ring buffer) and broadcast on
477/// `Engine::event_tx` for live subscribers.
478#[derive(Debug, Clone, Serialize, Deserialize)]
479#[serde(tag = "kind", rename_all = "snake_case")]
480pub enum Event {
481 /// A session was attached (`attach` / `attach_with` / `attach_with_ids`).
482 SessionAttached {
483 /// The newly attached session.
484 session_id: SessionId,
485 /// Role its token was minted with.
486 role: Role,
487 },
488 /// A session was detached (`detach`, or a heartbeat-miss timeout).
489 SessionDetached {
490 /// The session that was detached.
491 session_id: SessionId,
492 },
493 /// A new task was created via `start_task`.
494 TaskCreated {
495 /// The newly created task.
496 task_id: StepId,
497 },
498 /// An attempt began dispatching (not currently emitted by
499 /// `dispatch_attempt_with`; reserved for future use).
500 TaskAttemptStarted {
501 /// The task being dispatched.
502 task_id: StepId,
503 /// The attempt number.
504 attempt: u32,
505 },
506 /// An attempt finished, Pass or Blocked, with the resulting value.
507 TaskAttemptCompleted {
508 /// The task whose attempt completed.
509 task_id: StepId,
510 /// The attempt number that completed.
511 attempt: u32,
512 /// The result value produced by the attempt.
513 result: Value,
514 },
515 /// The task attempt completed with `ok = true`.
516 TaskPass {
517 /// The task that passed.
518 task_id: StepId,
519 /// The result value.
520 result: Value,
521 },
522 /// The task attempt completed with `ok = false`.
523 TaskBlocked {
524 /// The task that was blocked.
525 task_id: StepId,
526 /// The result/error value.
527 result: Value,
528 },
529 /// A worker appended an `OutputEvent` via `submit_output`.
530 WorkerOutput {
531 /// The task the output belongs to.
532 task_id: StepId,
533 /// The attempt the output belongs to.
534 attempt: u32,
535 /// The appended output event.
536 event: crate::worker::output::OutputEvent,
537 },
538 /// The task suspended pending a `resume` for `key`.
539 TaskSuspended {
540 /// The suspended task.
541 task_id: StepId,
542 /// The key needed to `resume` it.
543 key: ResumeKey,
544 },
545 /// The task resumed after `resume(key, ..)` was called.
546 TaskResumed {
547 /// The resumed task.
548 task_id: StepId,
549 /// The key that was resumed.
550 key: ResumeKey,
551 },
552 /// The task was cancelled via `cancel_task`.
553 TaskCancelled {
554 /// The cancelled task.
555 task_id: StepId,
556 },
557 /// `query_senior` was called, asking `question` on behalf of `task_id`.
558 SeniorQueried {
559 /// The task that triggered the query.
560 task_id: StepId,
561 /// The question posed to the Senior.
562 question: Value,
563 },
564 /// A Senior's `answer` was stored via `resume`.
565 SeniorAnswered {
566 /// The task the answer applies to.
567 task_id: StepId,
568 /// The Senior's answer.
569 answer: Value,
570 },
571}
572
573/// Receiver half of the engine-wide `Event` broadcast channel, obtained
574/// via `Engine::subscribe`.
575pub type EventStream = broadcast::Receiver<Event>;
576
577// ─── Resume pending (= Notify-based wait + stored answer) ─────────────────
578
579/// Entry for a task suspended via `query_senior`, waiting to be resumed.
580///
581/// The `Notify` + `answer: Option<Value>` form (rather than a oneshot
582/// channel) is deliberate: the answer stays inside `EngineState` even if
583/// the caller (an Operator) **detaches and reattaches**, so it can pull
584/// the answer out via `await_resume` after reattach.
585#[derive(Debug, Clone)]
586pub struct ResumePending {
587 /// Wakes any `await_resume` waiter once `answer` is set.
588 pub notify: Arc<Notify>,
589 /// The stored answer, once `resume` has been called for this key.
590 pub answer: Option<Value>,
591}
592
593impl ResumePending {
594 /// Create an unanswered pending entry (fresh `Notify`, `answer = None`).
595 pub fn new() -> Self {
596 Self {
597 notify: Arc::new(Notify::new()),
598 answer: None,
599 }
600 }
601}
602
603impl Default for ResumePending {
604 fn default() -> Self {
605 Self::new()
606 }
607}
608
609// ─── EngineState (= the locked thing) ──────────────────────────────────────
610
611/// One `(task_id, attempt)` entry of [`EngineState::agent_ctx`] — the
612/// materialized [`crate::core::agent_context::AgentContextView`] (Contract
613/// C, GH #20) and the effective [`mlua_swarm_schema::ContextPolicy`]
614/// `AgentContextMiddleware` already applied to it (`projection-adapter`
615/// ST5), folded into one struct (GH #23) so the two values — written
616/// together at the same single insert site — can no longer drift apart.
617#[derive(Debug, Clone, Default)]
618pub struct AgentCtxEntry {
619 /// The materialized view; the Worker axis's read-back source
620 /// (`Engine::fetch_worker_payload{,_trusted}` / `Engine::agent_context_for`).
621 pub view: crate::core::agent_context::AgentContextView,
622 /// The resolved policy already applied to `view`; read back by
623 /// `Engine::context_policy_for` so a consumer can filter a *different*
624 /// step's pointer list against this key's policy without re-deriving it
625 /// from the Blueprint.
626 pub policy: mlua_swarm_schema::ContextPolicy,
627}
628
629/// The single `Mutex`-guarded blob of engine flow state, accessed only
630/// through `Engine::with_state` (see the R1-R4 discipline documented
631/// there).
632#[derive(Debug)]
633pub struct EngineState {
634 /// All known tasks, keyed by `StepId`.
635 pub tasks: HashMap<StepId, TaskState>,
636 /// One [`LaunchEnvelope`] per `Engine::attach*`, keyed by the
637 /// `SessionId` minted with it. Named `sessions` because that is the
638 /// bookkeeping axis (a token, an attach, a detach); what each entry
639 /// holds is the launch envelope, not the Operator's WS session.
640 pub sessions: HashMap<SessionId, LaunchEnvelope>,
641 /// Per-`(task_id, attempt)` prompt/directive value, seeded from
642 /// `TaskSpec.initial_directive` and fetched via `fetch_prompt`. Held
643 /// as `serde_json::Value` end-to-end (issue #18): the render down to
644 /// `String` (strings verbatim, anything else serde-stringified)
645 /// happens only at the two consumer boundaries — the Worker HTTP
646 /// path (`Engine::fetch_worker_payload*` → `WorkerPayload.prompt:
647 /// String`) and the WS Spawn frame text render
648 /// (`operator_ws::session::default_spawn_directive_with_task_directive`).
649 /// Engine-internal storage and `Engine::fetch_prompt` keep the
650 /// `Value` end-to-end, so `Step.in` `Object` / `Array` seeds are not
651 /// prematurely flattened.
652 pub prompts: HashMap<(StepId, u32), Value>,
653 /// Per-attempt `system_prompt`: `AgentDef.profile.system_prompt` is
654 /// baked at compile time, rendered inside `OperatorSpawner::spawn`,
655 /// and stashed here for the SubAgent to fetch alongside its prompt via
656 /// `HTTP /v1/worker/prompt`. The value is `Option<String>` so a missing
657 /// profile can be distinguished: an absent key means "not yet baked",
658 /// while `Some(None)` means "baked and profile is explicitly absent".
659 pub systems: HashMap<(StepId, u32), Option<String>>,
660 /// GH #31: per-agent-name "most-recently-baked `system_prompt` size"
661 /// bookkeeping, updated by `Engine::bake_worker_system_prompt`
662 /// whenever `system.is_some()` (keyed by `TaskState.spec.agent`, the
663 /// same lookup `fetch_worker_payload` uses). Last-write-wins is the
664 /// deliberate semantics — "most-recently-observed", not "largest" or
665 /// "per-attempt" — since this exists only to let `bp_doctor`-style
666 /// tooling ask "how big does this agent's rendered system prompt
667 /// currently run" without plumbing the live `Engine` into
668 /// `BlueprintsState`. Read via `Engine::agent_last_rendered_size`.
669 pub agent_render_sizes: HashMap<String, usize>,
670 /// Per-attempt materialized [`crate::core::agent_context::AgentContextView`]
671 /// (Contract C, GH #20) paired with the effective
672 /// [`mlua_swarm_schema::ContextPolicy`] `AgentContextMiddleware` already
673 /// applied to that same view — the Worker axis's read-back source.
674 /// Written by `crate::middleware::agent_context::AgentContextMiddleware`
675 /// (innermost spawner layer) at dispatch time via one insert (GH #23:
676 /// folded from two separately-keyed maps — see below); read by
677 /// `Engine::fetch_worker_payload{,_trusted}` / `Engine::agent_context_for`
678 /// (the [`AgentCtxEntry::view`] half, threaded into `WorkerPayload.context`)
679 /// and `Engine::context_policy_for` (the [`AgentCtxEntry::policy`] half,
680 /// read by `crates/mlua-swarm-server/src/worker.rs`'s `GET
681 /// /v1/worker/prompt` handler to filter `WorkerPayload.context.steps` via
682 /// `ContextPolicy::allows_step` without re-deriving the policy from the
683 /// Blueprint at fetch time). Keyed the same way as `prompts` / `systems`
684 /// — `Ctx` itself is not stored, so the entry has to be snapshotted here
685 /// to still be servable at fetch time. An absent entry (pre-ST5 spawns,
686 /// or a spawner stack that never layered `AgentContextMiddleware`) means
687 /// no materialized view and a pass-all policy
688 /// (`mlua_swarm_schema::ContextPolicy::default()`) for that key.
689 ///
690 /// GH #23: this map used to be two separately-keyed maps
691 /// (`agent_contexts: HashMap<_, AgentContextView>` /
692 /// `context_policies: HashMap<_, ContextPolicy>`) sharing a key and kept
693 /// in sync only by convention (the single insert site in
694 /// `AgentContextMiddleware` writing both in the same `with_state` call).
695 /// Folding both values into one map of an [`AgentCtxEntry`] struct makes
696 /// that pairing structural — the two values can no longer drift apart
697 /// key-by-key. TODO(GH #23): this map (nor `prompts` / `systems`) still
698 /// has no removal path — entries accumulate for the process lifetime; a
699 /// long-running server needs a task-completion sweep (tracked
700 /// separately from this fold).
701 pub agent_ctx: HashMap<(StepId, u32), AgentCtxEntry>,
702 /// GH #23: per-dispatch snapshot of the Blueprint-wide
703 /// [`crate::core::step_naming::StepNaming`] addressing-space table —
704 /// built once by `blueprint::compiler::Compiler::compile` and stashed
705 /// here, per `StepId`, by `crate::blueprint::EngineDispatcher::dispatch`
706 /// (its single insert site; the same `Arc` is shared across every
707 /// Step dispatched from the same Blueprint launch). `Engine::step_naming_for`
708 /// reads it back so later consumers (GH #23 subtask-2/3 —
709 /// `ContextPolicy.allows_step`, `StepPointer`/`StepSummary` assembly,
710 /// the REST `:step` resolver, `FileProjectionAdapter`) do not have to
711 /// re-derive the table from the Blueprint at read time. An absent
712 /// entry means the dispatcher was never given a `StepNaming` (e.g. a
713 /// direct `EngineDispatcher::with_spawner` caller that skipped
714 /// `with_step_naming`) — callers fall back to the pre-GH-#23 runtime
715 /// union rule in that case.
716 pub step_namings: HashMap<StepId, Arc<crate::core::step_naming::StepNaming>>,
717 /// GH #27 (follow-up to #23): per-dispatch snapshot of the
718 /// Blueprint-wide [`crate::core::projection_placement::ProjectionPlacement`]
719 /// resolver — built once by `blueprint::compiler::Compiler::compile`
720 /// and stashed here, per `StepId`, by
721 /// `crate::blueprint::EngineDispatcher::dispatch` (the same insert
722 /// site, and the same "construct once, read many" contract, as
723 /// [`Self::step_namings`]). `Engine::projection_placement_for` reads
724 /// it back so every one of the 3 materialize call sites (submit-time
725 /// sink, server read-back, spawn-time pointer) resolves the SAME root
726 /// preference / directory template. An absent entry means the
727 /// dispatcher was never given a `ProjectionPlacement` (e.g. a direct
728 /// `EngineDispatcher::with_spawner` caller that skipped
729 /// `with_projection_placement`) — callers fall back to
730 /// `ProjectionPlacement::default()` (byte-compat with the pre-#27
731 /// hardcoded layout) in that case.
732 pub projection_placements:
733 HashMap<StepId, Arc<crate::core::projection_placement::ProjectionPlacement>>,
734 /// All minted `CapToken` records, keyed by token fingerprint
735 /// (`CapToken::fingerprint` = SHA-256 of the nonce; issue #14 — the
736 /// key is loggable, the nonce is not).
737 pub tokens: HashMap<String, CapTokenRecord>, // key = token fingerprint
738 /// Short worker handle (`wh-XXXXXXXX`, 12 chars) → token-fingerprint
739 /// lookup map. Resolves the `worker_handle` field a SubAgent receives
740 /// with its prompt. There is no signature verification: `task_id` is
741 /// resolved by a plain `HashMap` lookup — deliberately thin for the
742 /// local running over WebSocket, and adopted specifically to remove
743 /// the base64 copy-paste failure mode.
744 pub worker_handles: HashMap<String, String>,
745 /// Outstanding `query_senior` suspensions awaiting `resume`.
746 pub pending_resumes: HashMap<ResumeKey, ResumePending>,
747 /// Per-task notifier — `notify_waiters` fires on every task-status
748 /// change. Used by `poll_task` on the caller side, and by callers that
749 /// need to `await` again after detach/reattach.
750 pub task_notifies: HashMap<StepId, Arc<Notify>>,
751 /// Arbitrary named resources set via `set_resource` and read via
752 /// `fetch_data`.
753 pub resources: HashMap<String, Value>,
754 /// Per-attempt output-event log. The `SpawnerAdapter` appends via
755 /// `submit_output`; the dispatch path pulls the terminal
756 /// `OutputEvent::Final` off the tail and decides Pass / Blocked.
757 pub output_store: HashMap<(StepId, u32), Vec<crate::worker::output::OutputEvent>>,
758 /// GH #36 ST1 (named multi-part worker output): the set of `Artifact`
759 /// names a WORKER staged for `(task_id, attempt)`, in staging order.
760 ///
761 /// Two population paths, one per lane, both meaning "the worker itself
762 /// staged this part": `Engine::stage_worker_artifact_trusted` (= `POST
763 /// /v1/worker/artifact`, the out-of-process lane) and
764 /// `crate::worker::output::EngineSink::emit` (the in-process lane's
765 /// `WorkerInvocation.sink`, which `InProcSpawner::spawn` is the sole
766 /// constructor of — so an `Artifact` arriving through it is by
767 /// construction the worker's own).
768 ///
769 /// `output_store` is a SHARED per-attempt tail — besides a worker's own
770 /// staged parts, other producers append `OutputEvent::Artifact` events
771 /// to the SAME tail too (e.g. `AfterRunAuditMiddleware`'s
772 /// `"audit:<step_ref>"` sidecar finding, an intentionally
773 /// BP-chain-invisible observation — see `Engine::submit_output`'s doc,
774 /// "`Artifact` dual-write" section). `Engine::dispatch_attempt_with`'s
775 /// Final-pull assembly must fold ONLY a worker's own named parts into
776 /// `"parts"`, not every `Artifact` that happens to land on the tail —
777 /// this set is that distinguishing signal, so a step under audit keeps
778 /// its BP-chain value byte-identical to pre-GH-#36 unless the WORKER
779 /// itself opted in.
780 pub worker_artifact_names: HashMap<(StepId, u32), Vec<String>>,
781 /// Bounded in-process tail of recent `Event`s (most recent last),
782 /// trimmed to `event_log_max` by `push_event`.
783 pub event_log_tail: Vec<Event>,
784 /// Maximum length of `event_log_tail` before older entries are
785 /// dropped.
786 pub event_log_max: usize,
787 /// Per-attempt normalized worker stats reported by worker
788 /// boundaries (`Engine::record_worker_stats`) and drained by the
789 /// dispatcher's outcome fold (`Engine::take_worker_stats`) into the
790 /// terminal `StepEntry`. Entries a dispatcher never drains (direct
791 /// `dispatch_attempt_with` callers without an `EngineDispatcher`)
792 /// share the process-lifetime accumulation caveat of `prompts` /
793 /// `agent_ctx` (GH #23 sweep TODO).
794 pub worker_stats: HashMap<(StepId, u32), crate::store::trace::WorkerStats>,
795 /// Per-dispatch [`crate::store::trace::TraceHandle`] registry —
796 /// inserted by `EngineDispatcher::dispatch` before spawning a step
797 /// (when its `RunContext` carries a trace handle), removed after
798 /// the outcome fold. Middlewares and other engine-adjacent writers
799 /// read it via `Engine::trace_handle` to append their own trace
800 /// kinds without any plumbing through `Ctx`. Known limitation
801 /// (holistic review, LOW): a dispatch future dropped between insert
802 /// and fold (sync-launch timeout race, caller abort) strands its
803 /// entry for the process lifetime — the same accumulation caveat as
804 /// `worker_stats` / `prompts` (GH #23 sweep TODO); each stranded
805 /// entry is one map slot + an `Arc` clone, not a per-run buffer.
806 pub trace_handles: HashMap<StepId, crate::store::trace::TraceHandle>,
807}
808
809impl EngineState {
810 /// Append `name` to the worker's own staged-part allowlist for
811 /// `(task_id, attempt)` — see [`Self::worker_artifact_names`].
812 ///
813 /// One statement, but shared by both lanes' population paths so the
814 /// "which map, keyed how" decision lives in one place: a lane that
815 /// records into the wrong shape would silently drop its parts out of
816 /// the `{out, parts}` fold rather than fail.
817 pub(crate) fn record_worker_artifact_name(
818 &mut self,
819 task_id: StepId,
820 attempt: u32,
821 name: String,
822 ) {
823 self.worker_artifact_names
824 .entry((task_id, attempt))
825 .or_default()
826 .push(name);
827 }
828
829 /// Construct an empty `EngineState` with `event_log_max = 1024`.
830 pub fn new() -> Self {
831 Self {
832 tasks: HashMap::new(),
833 sessions: HashMap::new(),
834 prompts: HashMap::new(),
835 systems: HashMap::new(),
836 agent_render_sizes: HashMap::new(),
837 agent_ctx: HashMap::new(),
838 step_namings: HashMap::new(),
839 projection_placements: HashMap::new(),
840 tokens: HashMap::new(),
841 worker_handles: HashMap::new(),
842 pending_resumes: HashMap::new(),
843 task_notifies: HashMap::new(),
844 resources: HashMap::new(),
845 output_store: HashMap::new(),
846 worker_artifact_names: HashMap::new(),
847 event_log_tail: Vec::new(),
848 event_log_max: 1024,
849 worker_stats: HashMap::new(),
850 trace_handles: HashMap::new(),
851 }
852 }
853
854 /// Ensure a per-task `Notify` exists; return the existing one if any.
855 pub fn ensure_task_notify(&mut self, task_id: &StepId) -> Arc<Notify> {
856 self.task_notifies
857 .entry(task_id.clone())
858 .or_insert_with(|| Arc::new(Notify::new()))
859 .clone()
860 }
861
862 /// Append `ev` to `event_log_tail`, trimming the oldest entries once
863 /// `event_log_max` is exceeded.
864 pub fn push_event(&mut self, ev: Event) {
865 self.event_log_tail.push(ev);
866 if self.event_log_tail.len() > self.event_log_max {
867 let overflow = self.event_log_tail.len() - self.event_log_max;
868 self.event_log_tail.drain(..overflow);
869 }
870 }
871}
872
873impl Default for EngineState {
874 fn default() -> Self {
875 Self::new()
876 }
877}