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 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/// Result of a `dispatch_attempt_with` call (or the conceptual outcome of
158/// a task attempt more broadly).
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub enum DispatchOutcome {
161    /// The attempt completed with `ok = true`; carries the result value.
162    Pass(Value),
163    /// The attempt completed with `ok = false`, or dispatch itself failed;
164    /// carries the result/error value.
165    Blocked(Value),
166    /// The task suspended (e.g. via `query_senior`) before completing;
167    /// carries the key needed to `resume` it.
168    Suspended(ResumeKey),
169    /// The task was cancelled before completing.
170    Cancelled,
171    /// The attempt did not complete within the allotted time.
172    Timeout,
173}
174
175// ─── Session ───────────────────────────────────────────────────────────────
176
177/// Persisted record of one attached Operator session: identity, role,
178/// heartbeat bookkeeping, owned tasks, and the `OperatorKind` cascade
179/// inputs plus registry IDs used to rebuild `OperatorInfo` on dispatch
180/// (see `Engine::resolve_operator_info`).
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct OperatorSession {
183    /// Unique session identifier (distinct from the token nonce).
184    pub id: SessionId,
185    /// Caller-supplied name identifying the Operator (not necessarily
186    /// unique across sessions).
187    pub operator_id: String,
188    /// Role the session's token was minted with.
189    pub role: Role,
190    /// Unix timestamp (seconds) when the session was attached.
191    pub attached_at: u64,
192    /// Unix timestamp (seconds) of the last heartbeat/attach touch.
193    pub last_seen: u64,
194    /// Whether the session is currently considered live. Flipped to
195    /// `false` by `detach` or by `start_detach_loop` on a heartbeat miss.
196    pub attached: bool,
197    /// Task IDs started by this session (via `start_task` while this
198    /// session's token was current).
199    pub owned_task_ids: Vec<StepId>,
200    /// Fingerprint (`CapToken::fingerprint`, SHA-256 of the nonce) of the
201    /// `CapToken` this session was attached with; used to look sessions up
202    /// by token in `with_state` closures. Holds the fingerprint rather
203    /// than the nonce so the session table carries no secret material
204    /// (issue #14).
205    pub token_fp: String,
206    /// The Operator's `kind`, plus IDs of
207    /// the `SeniorBridge` / `SpawnHook` registered on the engine's
208    /// `BridgeRegistry`. Persisted (all `String`; no `Arc<dyn ...>`). At
209    /// `dispatch_attempt` time the engine looks these up in the registry
210    /// and builds an `OperatorInfo` to inject into `Ctx`.
211    ///
212    /// # 4-tier `OperatorKind` cascade — "Runtime Global" tier
213    ///
214    /// This field is the literal value passed to `Engine::attach_with_ids`'s
215    /// `kind` parameter, and is fed to `crate::core::ctx::collapse_operator_kind`
216    /// as the `runtime_global` tier verbatim: `Some(_)` is always an
217    /// explicit Runtime Global request that outranks both BP tiers — even
218    /// `Some(OperatorKind::Automate)` — and `None` means "not requested",
219    /// letting the BP-level tiers (`bp_agent_kinds` / `bp_global_kind`) take
220    /// over. `#[serde(default)]` keeps existing persisted sessions (from
221    /// before this field existed / was `Option`) deserializing as `None`.
222    /// See `crate::core::ctx::collapse_operator_kind` for the full cascade +
223    /// rationale.
224    #[serde(default)]
225    pub operator_kind: Option<crate::core::ctx::OperatorKind>,
226    /// "Runtime Agent-level" tier (highest priority) of the `OperatorKind`
227    /// cascade — per-agent override supplied at task-launch time via
228    /// `TaskLaunchInput.operator_kind_overrides` / `TaskApplicationInput
229    /// .operator_kind_overrides`. Keyed by `AgentDef.name`.
230    #[serde(default)]
231    pub runtime_agent_kinds: HashMap<String, crate::core::ctx::OperatorKind>,
232    /// "BP Agent-level" tier of the `OperatorKind` cascade — baked at
233    /// `TaskLaunchService::launch` time from `Blueprint.operators[].kind`,
234    /// resolved per-agent via `AgentDef.spec.operator_ref`. Keyed by
235    /// `AgentDef.name` (not `OperatorDef.name`).
236    #[serde(default)]
237    pub bp_agent_kinds: HashMap<String, crate::core::ctx::OperatorKind>,
238    /// "BP Global" tier of the `OperatorKind` cascade — baked at
239    /// `TaskLaunchService::launch` time from `Blueprint.default_operator_kind`.
240    #[serde(default)]
241    pub bp_global_kind: Option<crate::core::ctx::OperatorKind>,
242    /// ID of the `Arc<dyn SeniorBridge>` registered on the engine's
243    /// `BridgeRegistry`, if any; resolved back into `OperatorInfo.senior_bridge`.
244    #[serde(default)]
245    pub bridge_id: Option<String>,
246    /// ID of the `Arc<dyn SpawnHook>` registered on the engine's
247    /// `BridgeRegistry`, if any; resolved back into `OperatorInfo.spawn_hook`.
248    #[serde(default)]
249    pub hook_id: Option<String>,
250    /// ID of the `Arc<dyn Operator>` registered on the `OperatorRegistry`.
251    /// Used by `OperatorDelegateMiddleware` when `kind = MainAi` /
252    /// `Composite` and `operator_id` is `Some`: it delegates the entire
253    /// spawn to `operator.execute`.
254    #[serde(default)]
255    pub operator_backend_id: Option<String>,
256}
257
258// ─── Token record (= server-side counter holder) ──────────────────────────
259
260/// Server-side counter/state holder paired 1:1 with a minted `CapToken`
261/// (keyed by nonce in `EngineState.tokens`). Tracks remaining uses,
262/// revocation, and — for Worker tokens — the task the token is bound to.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct CapTokenRecord {
265    /// The token this record backs.
266    pub token: CapToken,
267    /// Remaining number of verb-consuming calls. `None` means unlimited
268    /// (session-style tokens); `Some(0)` makes `consume` fail.
269    pub uses_left: Option<u32>, // None = unlimited (session)
270    /// When `true`, `consume` always fails regardless of `uses_left`.
271    pub revoked: bool,
272    /// The task this Worker token is bound to (set when minted via
273    /// `dispatch_attempt`). Used on two axes:
274    ///   1. **Depth tracking.** When a Worker calls `start_task` to spawn a
275    ///      child, the child receives this task's `spawn_depth + 1`.
276    ///   2. **Ownership gate.** When a Worker calls a state-touch verb
277    ///      (`fetch_prompt` / `post_result` / `read_task_state` /
278    ///      `cancel_task` / `poll_task`), the argument's `task_id` must
279    ///      match this value. `start_task`
280    ///      and `dispatch_attempt` are exempt — recursive swarming must
281    ///      stay open, and depth is capped by `max_spawn_depth`.
282    ///
283    ///      Operator tokens (minted at attach time) leave this `None`, so
284    ///      they can touch any task.
285    #[serde(default)]
286    pub task_id: Option<StepId>,
287}
288
289impl CapTokenRecord {
290    /// Wrap a freshly minted `CapToken` with no bound task (`task_id =
291    /// None`) — the shape used for Operator/session tokens.
292    pub fn from_token(token: CapToken) -> Self {
293        Self {
294            uses_left: token.max_uses,
295            token,
296            revoked: false,
297            task_id: None,
298        }
299    }
300
301    /// Convenience constructor used when minting a Worker token — binds
302    /// the record to the target task.
303    pub fn from_worker_token(token: CapToken, task_id: StepId) -> Self {
304        Self {
305            uses_left: token.max_uses,
306            token,
307            revoked: false,
308            task_id: Some(task_id),
309        }
310    }
311
312    /// Consume one use. `None` (session token) always returns `Ok`;
313    /// `Some(0)` returns `Err`.
314    pub fn consume(&mut self) -> Result<(), CapTokenConsumeError> {
315        if self.revoked {
316            return Err(CapTokenConsumeError::Revoked);
317        }
318        match self.uses_left.as_mut() {
319            None => Ok(()),
320            Some(0) => Err(CapTokenConsumeError::Exhausted),
321            Some(n) => {
322                *n -= 1;
323                Ok(())
324            }
325        }
326    }
327}
328
329/// Why [`CapTokenRecord::consume`] refused to spend a use.
330#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
331pub enum CapTokenConsumeError {
332    /// The record was explicitly revoked (`revoked = true`); revocation
333    /// is permanent and independent of `uses_left`.
334    #[error("token revoked")]
335    Revoked,
336    /// The record's `uses_left` budget (`Some(0)`) is spent.
337    #[error("token uses exhausted")]
338    Exhausted,
339}
340
341// ─── Event ─────────────────────────────────────────────────────────────────
342
343/// Engine lifecycle event. Every event is both appended to
344/// `EngineState.event_log_tail` (in-process ring buffer) and broadcast on
345/// `Engine::event_tx` for live subscribers.
346#[derive(Debug, Clone, Serialize, Deserialize)]
347#[serde(tag = "kind", rename_all = "snake_case")]
348pub enum Event {
349    /// A session was attached (`attach` / `attach_with` / `attach_with_ids`).
350    SessionAttached {
351        /// The newly attached session.
352        session_id: SessionId,
353        /// Role its token was minted with.
354        role: Role,
355    },
356    /// A session was detached (`detach`, or a heartbeat-miss timeout).
357    SessionDetached {
358        /// The session that was detached.
359        session_id: SessionId,
360    },
361    /// A new task was created via `start_task`.
362    TaskCreated {
363        /// The newly created task.
364        task_id: StepId,
365    },
366    /// An attempt began dispatching (not currently emitted by
367    /// `dispatch_attempt_with`; reserved for future use).
368    TaskAttemptStarted {
369        /// The task being dispatched.
370        task_id: StepId,
371        /// The attempt number.
372        attempt: u32,
373    },
374    /// An attempt finished, Pass or Blocked, with the resulting value.
375    TaskAttemptCompleted {
376        /// The task whose attempt completed.
377        task_id: StepId,
378        /// The attempt number that completed.
379        attempt: u32,
380        /// The result value produced by the attempt.
381        result: Value,
382    },
383    /// The task attempt completed with `ok = true`.
384    TaskPass {
385        /// The task that passed.
386        task_id: StepId,
387        /// The result value.
388        result: Value,
389    },
390    /// The task attempt completed with `ok = false`.
391    TaskBlocked {
392        /// The task that was blocked.
393        task_id: StepId,
394        /// The result/error value.
395        result: Value,
396    },
397    /// A worker appended an `OutputEvent` via `submit_output`.
398    WorkerOutput {
399        /// The task the output belongs to.
400        task_id: StepId,
401        /// The attempt the output belongs to.
402        attempt: u32,
403        /// The appended output event.
404        event: crate::worker::output::OutputEvent,
405    },
406    /// The task suspended pending a `resume` for `key`.
407    TaskSuspended {
408        /// The suspended task.
409        task_id: StepId,
410        /// The key needed to `resume` it.
411        key: ResumeKey,
412    },
413    /// The task resumed after `resume(key, ..)` was called.
414    TaskResumed {
415        /// The resumed task.
416        task_id: StepId,
417        /// The key that was resumed.
418        key: ResumeKey,
419    },
420    /// The task was cancelled via `cancel_task`.
421    TaskCancelled {
422        /// The cancelled task.
423        task_id: StepId,
424    },
425    /// `query_senior` was called, asking `question` on behalf of `task_id`.
426    SeniorQueried {
427        /// The task that triggered the query.
428        task_id: StepId,
429        /// The question posed to the Senior.
430        question: Value,
431    },
432    /// A Senior's `answer` was stored via `resume`.
433    SeniorAnswered {
434        /// The task the answer applies to.
435        task_id: StepId,
436        /// The Senior's answer.
437        answer: Value,
438    },
439}
440
441/// Receiver half of the engine-wide `Event` broadcast channel, obtained
442/// via `Engine::subscribe`.
443pub type EventStream = broadcast::Receiver<Event>;
444
445// ─── Resume pending (= Notify-based wait + stored answer) ─────────────────
446
447/// Entry for a task suspended via `query_senior`, waiting to be resumed.
448///
449/// The `Notify` + `answer: Option<Value>` form (rather than a oneshot
450/// channel) is deliberate: the answer stays inside `EngineState` even if
451/// the caller (an Operator) **detaches and reattaches**, so it can pull
452/// the answer out via `await_resume` after reattach.
453#[derive(Debug, Clone)]
454pub struct ResumePending {
455    /// Wakes any `await_resume` waiter once `answer` is set.
456    pub notify: Arc<Notify>,
457    /// The stored answer, once `resume` has been called for this key.
458    pub answer: Option<Value>,
459}
460
461impl ResumePending {
462    /// Create an unanswered pending entry (fresh `Notify`, `answer = None`).
463    pub fn new() -> Self {
464        Self {
465            notify: Arc::new(Notify::new()),
466            answer: None,
467        }
468    }
469}
470
471impl Default for ResumePending {
472    fn default() -> Self {
473        Self::new()
474    }
475}
476
477// ─── EngineState (= the locked thing) ──────────────────────────────────────
478
479/// One `(task_id, attempt)` entry of [`EngineState::agent_ctx`] — the
480/// materialized [`crate::core::agent_context::AgentContextView`] (Contract
481/// C, GH #20) and the effective [`mlua_swarm_schema::ContextPolicy`]
482/// `AgentContextMiddleware` already applied to it (`projection-adapter`
483/// ST5), folded into one struct (GH #23) so the two values — written
484/// together at the same single insert site — can no longer drift apart.
485#[derive(Debug, Clone, Default)]
486pub struct AgentCtxEntry {
487    /// The materialized view; the Worker axis's read-back source
488    /// (`Engine::fetch_worker_payload{,_trusted}` / `Engine::agent_context_for`).
489    pub view: crate::core::agent_context::AgentContextView,
490    /// The resolved policy already applied to `view`; read back by
491    /// `Engine::context_policy_for` so a consumer can filter a *different*
492    /// step's pointer list against this key's policy without re-deriving it
493    /// from the Blueprint.
494    pub policy: mlua_swarm_schema::ContextPolicy,
495}
496
497/// The single `Mutex`-guarded blob of engine flow state, accessed only
498/// through `Engine::with_state` (see the R1-R4 discipline documented
499/// there).
500#[derive(Debug)]
501pub struct EngineState {
502    /// All known tasks, keyed by `StepId`.
503    pub tasks: HashMap<StepId, TaskState>,
504    /// All attached/detached sessions, keyed by `SessionId`.
505    pub sessions: HashMap<SessionId, OperatorSession>,
506    /// Per-`(task_id, attempt)` prompt/directive value, seeded from
507    /// `TaskSpec.initial_directive` and fetched via `fetch_prompt`. Held
508    /// as `serde_json::Value` end-to-end (issue #18): the render down to
509    /// `String` (strings verbatim, anything else serde-stringified)
510    /// happens only at the two consumer boundaries — the Worker HTTP
511    /// path (`Engine::fetch_worker_payload*` → `WorkerPayload.prompt:
512    /// String`) and the WS Spawn frame text render
513    /// (`operator_ws::session::default_spawn_directive_with_task_directive`).
514    /// Engine-internal storage and `Engine::fetch_prompt` keep the
515    /// `Value` end-to-end, so `Step.in` `Object` / `Array` seeds are not
516    /// prematurely flattened.
517    pub prompts: HashMap<(StepId, u32), Value>,
518    /// Per-attempt `system_prompt`: `AgentDef.profile.system_prompt` is
519    /// baked at compile time, rendered inside `OperatorSpawner::spawn`,
520    /// and stashed here for the SubAgent to fetch alongside its prompt via
521    /// `HTTP /v1/worker/prompt`. The value is `Option<String>` so a missing
522    /// profile can be distinguished: an absent key means "not yet baked",
523    /// while `Some(None)` means "baked and profile is explicitly absent".
524    pub systems: HashMap<(StepId, u32), Option<String>>,
525    /// GH #31: per-agent-name "most-recently-baked `system_prompt` size"
526    /// bookkeeping, updated by `Engine::bake_worker_system_prompt`
527    /// whenever `system.is_some()` (keyed by `TaskState.spec.agent`, the
528    /// same lookup `fetch_worker_payload` uses). Last-write-wins is the
529    /// deliberate semantics — "most-recently-observed", not "largest" or
530    /// "per-attempt" — since this exists only to let `bp_doctor`-style
531    /// tooling ask "how big does this agent's rendered system prompt
532    /// currently run" without plumbing the live `Engine` into
533    /// `BlueprintsState`. Read via `Engine::agent_last_rendered_size`.
534    pub agent_render_sizes: HashMap<String, usize>,
535    /// Per-attempt materialized [`crate::core::agent_context::AgentContextView`]
536    /// (Contract C, GH #20) paired with the effective
537    /// [`mlua_swarm_schema::ContextPolicy`] `AgentContextMiddleware` already
538    /// applied to that same view — the Worker axis's read-back source.
539    /// Written by `crate::middleware::agent_context::AgentContextMiddleware`
540    /// (innermost spawner layer) at dispatch time via one insert (GH #23:
541    /// folded from two separately-keyed maps — see below); read by
542    /// `Engine::fetch_worker_payload{,_trusted}` / `Engine::agent_context_for`
543    /// (the [`AgentCtxEntry::view`] half, threaded into `WorkerPayload.context`)
544    /// and `Engine::context_policy_for` (the [`AgentCtxEntry::policy`] half,
545    /// read by `crates/mlua-swarm-server/src/worker.rs`'s `GET
546    /// /v1/worker/prompt` handler to filter `WorkerPayload.context.steps` via
547    /// `ContextPolicy::allows_step` without re-deriving the policy from the
548    /// Blueprint at fetch time). Keyed the same way as `prompts` / `systems`
549    /// — `Ctx` itself is not stored, so the entry has to be snapshotted here
550    /// to still be servable at fetch time. An absent entry (pre-ST5 spawns,
551    /// or a spawner stack that never layered `AgentContextMiddleware`) means
552    /// no materialized view and a pass-all policy
553    /// (`mlua_swarm_schema::ContextPolicy::default()`) for that key.
554    ///
555    /// GH #23: this map used to be two separately-keyed maps
556    /// (`agent_contexts: HashMap<_, AgentContextView>` /
557    /// `context_policies: HashMap<_, ContextPolicy>`) sharing a key and kept
558    /// in sync only by convention (the single insert site in
559    /// `AgentContextMiddleware` writing both in the same `with_state` call).
560    /// Folding both values into one map of an [`AgentCtxEntry`] struct makes
561    /// that pairing structural — the two values can no longer drift apart
562    /// key-by-key. TODO(GH #23): this map (nor `prompts` / `systems`) still
563    /// has no removal path — entries accumulate for the process lifetime; a
564    /// long-running server needs a task-completion sweep (tracked
565    /// separately from this fold).
566    pub agent_ctx: HashMap<(StepId, u32), AgentCtxEntry>,
567    /// GH #23: per-dispatch snapshot of the Blueprint-wide
568    /// [`crate::core::step_naming::StepNaming`] addressing-space table —
569    /// built once by `blueprint::compiler::Compiler::compile` and stashed
570    /// here, per `StepId`, by `crate::blueprint::EngineDispatcher::dispatch`
571    /// (its single insert site; the same `Arc` is shared across every
572    /// Step dispatched from the same Blueprint launch). `Engine::step_naming_for`
573    /// reads it back so later consumers (GH #23 subtask-2/3 —
574    /// `ContextPolicy.allows_step`, `StepPointer`/`StepSummary` assembly,
575    /// the REST `:step` resolver, `FileProjectionAdapter`) do not have to
576    /// re-derive the table from the Blueprint at read time. An absent
577    /// entry means the dispatcher was never given a `StepNaming` (e.g. a
578    /// direct `EngineDispatcher::with_spawner` caller that skipped
579    /// `with_step_naming`) — callers fall back to the pre-GH-#23 runtime
580    /// union rule in that case.
581    pub step_namings: HashMap<StepId, Arc<crate::core::step_naming::StepNaming>>,
582    /// GH #27 (follow-up to #23): per-dispatch snapshot of the
583    /// Blueprint-wide [`crate::core::projection_placement::ProjectionPlacement`]
584    /// resolver — built once by `blueprint::compiler::Compiler::compile`
585    /// and stashed here, per `StepId`, by
586    /// `crate::blueprint::EngineDispatcher::dispatch` (the same insert
587    /// site, and the same "construct once, read many" contract, as
588    /// [`Self::step_namings`]). `Engine::projection_placement_for` reads
589    /// it back so every one of the 3 materialize call sites (submit-time
590    /// sink, server read-back, spawn-time pointer) resolves the SAME root
591    /// preference / directory template. An absent entry means the
592    /// dispatcher was never given a `ProjectionPlacement` (e.g. a direct
593    /// `EngineDispatcher::with_spawner` caller that skipped
594    /// `with_projection_placement`) — callers fall back to
595    /// `ProjectionPlacement::default()` (byte-compat with the pre-#27
596    /// hardcoded layout) in that case.
597    pub projection_placements:
598        HashMap<StepId, Arc<crate::core::projection_placement::ProjectionPlacement>>,
599    /// All minted `CapToken` records, keyed by token fingerprint
600    /// (`CapToken::fingerprint` = SHA-256 of the nonce; issue #14 — the
601    /// key is loggable, the nonce is not).
602    pub tokens: HashMap<String, CapTokenRecord>, // key = token fingerprint
603    /// Short worker handle (`wh-XXXXXXXX`, 12 chars) → token-fingerprint
604    /// lookup map. Resolves the `worker_handle` field a SubAgent receives
605    /// with its prompt. There is no signature verification: `task_id` is
606    /// resolved by a plain `HashMap` lookup — deliberately thin for the
607    /// local running over WebSocket, and adopted specifically to remove
608    /// the base64 copy-paste failure mode.
609    pub worker_handles: HashMap<String, String>,
610    /// Outstanding `query_senior` suspensions awaiting `resume`.
611    pub pending_resumes: HashMap<ResumeKey, ResumePending>,
612    /// Per-task notifier — `notify_waiters` fires on every task-status
613    /// change. Used by `poll_task` on the caller side, and by callers that
614    /// need to `await` again after detach/reattach.
615    pub task_notifies: HashMap<StepId, Arc<Notify>>,
616    /// Arbitrary named resources set via `set_resource` and read via
617    /// `fetch_data`.
618    pub resources: HashMap<String, Value>,
619    /// Per-attempt output-event log. The `SpawnerAdapter` appends via
620    /// `submit_output`; the dispatch path pulls the terminal
621    /// `OutputEvent::Final` off the tail and decides Pass / Blocked.
622    pub output_store: HashMap<(StepId, u32), Vec<crate::worker::output::OutputEvent>>,
623    /// GH #36 ST1 (named multi-part worker output): the set of `Artifact`
624    /// names staged via `Engine::stage_worker_artifact_trusted` (= `POST
625    /// /v1/worker/artifact`) for `(task_id, attempt)`, in staging order.
626    ///
627    /// `output_store` is a SHARED per-attempt tail — besides a worker's own
628    /// staged parts, other producers append `OutputEvent::Artifact` events
629    /// to the SAME tail too (e.g. `AfterRunAuditMiddleware`'s
630    /// `"audit:<step_ref>"` sidecar finding, an intentionally
631    /// BP-chain-invisible observation — see `Engine::submit_output`'s doc,
632    /// "`Artifact` dual-write" section). `Engine::dispatch_attempt_with`'s
633    /// Final-pull assembly must fold ONLY a worker's own named parts into
634    /// `"parts"`, not every `Artifact` that happens to land on the tail —
635    /// this set is that distinguishing signal, so a step under audit keeps
636    /// its BP-chain value byte-identical to pre-GH-#36 unless the WORKER
637    /// itself opted in via `/v1/worker/artifact`.
638    pub worker_artifact_names: HashMap<(StepId, u32), Vec<String>>,
639    /// Bounded in-process tail of recent `Event`s (most recent last),
640    /// trimmed to `event_log_max` by `push_event`.
641    pub event_log_tail: Vec<Event>,
642    /// Maximum length of `event_log_tail` before older entries are
643    /// dropped.
644    pub event_log_max: usize,
645}
646
647impl EngineState {
648    /// Construct an empty `EngineState` with `event_log_max = 1024`.
649    pub fn new() -> Self {
650        Self {
651            tasks: HashMap::new(),
652            sessions: HashMap::new(),
653            prompts: HashMap::new(),
654            systems: HashMap::new(),
655            agent_render_sizes: HashMap::new(),
656            agent_ctx: HashMap::new(),
657            step_namings: HashMap::new(),
658            projection_placements: HashMap::new(),
659            tokens: HashMap::new(),
660            worker_handles: HashMap::new(),
661            pending_resumes: HashMap::new(),
662            task_notifies: HashMap::new(),
663            resources: HashMap::new(),
664            output_store: HashMap::new(),
665            worker_artifact_names: HashMap::new(),
666            event_log_tail: Vec::new(),
667            event_log_max: 1024,
668        }
669    }
670
671    /// Ensure a per-task `Notify` exists; return the existing one if any.
672    pub fn ensure_task_notify(&mut self, task_id: &StepId) -> Arc<Notify> {
673        self.task_notifies
674            .entry(task_id.clone())
675            .or_insert_with(|| Arc::new(Notify::new()))
676            .clone()
677    }
678
679    /// Append `ev` to `event_log_tail`, trimming the oldest entries once
680    /// `event_log_max` is exceeded.
681    pub fn push_event(&mut self, ev: Event) {
682        self.event_log_tail.push(ev);
683        if self.event_log_tail.len() > self.event_log_max {
684            let overflow = self.event_log_tail.len() - self.event_log_max;
685            self.event_log_tail.drain(..overflow);
686        }
687    }
688}
689
690impl Default for EngineState {
691    fn default() -> Self {
692        Self::new()
693    }
694}