mlua_swarm/core/engine.rs
1//! `Engine` — the long-running stateful runtime plus the `with_state`
2//! helper (R1-R4 discipline).
3//!
4//! The engine owns the Domain side of the Data / Domain split:
5//! flow control (dispatch / verdict), state (`EngineState`), and the
6//! `submit_output` / `output_tail` surface that feeds it. Data-plane
7//! traffic (Big Response bodies) is delegated to the `output_store` module
8//! plus its paired `SpawnerLayer`s and passes through here without the
9//! engine core needing to grow.
10
11use crate::core::agent_context::{RUN_ID_KEY, STEP_CTX_KEY};
12use crate::core::config::EngineCfg;
13use crate::core::ctx::{Ctx, OperatorInfo, OperatorKind, SeniorBridge, SpawnHook};
14use crate::core::errors::EngineError;
15use crate::core::state::{
16 unwrap_skip_marker, wrap_skip_marker, CapTokenRecord, DispatchOutcome, EngineState, Event,
17 EventStream, OperatorSession, ResumeKey, ResumePending, SubmitOutcome, TaskSpec, TaskState,
18 TaskStatus,
19};
20use crate::store::replay::{hash_input_value, ReplayEntry};
21use crate::store::run::RunContext;
22use crate::types::{
23 default_role_verb_table, now_unix, CapToken, Role, RoleVerbGate, RunId, SessionId, StepId,
24 TokenSigner, Verb,
25};
26use crate::worker::adapter::SpawnerAdapter;
27use serde_json::Value;
28use std::collections::HashMap;
29use std::sync::Arc;
30use std::time::{Duration, Instant};
31use tokio::sync::{broadcast, Mutex};
32
33/// Process-wide long-running runtime. Cheap to `clone()` — an `Arc`
34/// lives inside.
35#[derive(Clone)]
36pub struct Engine {
37 inner: Arc<EngineInner>,
38}
39
40struct EngineInner {
41 state: Mutex<EngineState>,
42 cfg: EngineCfg,
43 signer: TokenSigner,
44 gate: RoleVerbGate,
45 event_tx: broadcast::Sender<Event>,
46 /// ID-keyed bridge registry (register-by-ID design). `SeniorBridge`
47 /// and `SpawnHook` are registered by ID; sessions bind to those IDs
48 /// only. Persistence stores just the ID, and on reattach the caller
49 /// re-registers under the same ID to restore presence.
50 senior_bridges: tokio::sync::RwLock<HashMap<String, Arc<dyn SeniorBridge>>>,
51 spawn_hooks: tokio::sync::RwLock<HashMap<String, Arc<dyn SpawnHook>>>,
52 /// ID registry for full-spawn Operator backends (backends that take the
53 /// entire spawn via `execute`). Sibling to `senior_bridges` /
54 /// `spawn_hooks`. `OperatorDelegateMiddleware` looks these up via
55 /// `ctx` and, when `kind = MainAi` / `Composite`, bypasses
56 /// `inner.spawn` and calls `operator.execute` instead.
57 operators: tokio::sync::RwLock<HashMap<String, Arc<dyn crate::operator::Operator>>>,
58 /// Base and hint layer factories for the `SpawnerStack`. At
59 /// `service::linker::link` time, `compiled.router` is wrapped with
60 /// the base factories plus the hint factories resolved from
61 /// `blueprint.spawner_hints.layers`. This is the engine-side
62 /// counterpart to the discipline "Flow / Blueprint doesn't spell out
63 /// middleware implementations — it declares the capabilities it needs
64 /// as hint keys".
65 layer_registry: crate::middleware::LayerRegistry,
66 /// Optional Data-plane `OutputStore` backend (subtask-4 / ST2 rework —
67 /// see `submit_output`'s doc). `None` (the default) preserves
68 /// pre-subtask-4 behavior exactly: `submit_output` /
69 /// `submit_worker_result_trusted` only touch the Domain-plane
70 /// `EngineState.output_store` HashMap, same as before this was added.
71 /// `Some` additionally dual-writes every `Final` event into this store
72 /// via [`crate::store::output::OutputStore::append`], making it
73 /// queryable (e.g. by `mlua-swarm-server`'s `GET /v1/tasks/:id/ctx`)
74 /// even for an in-flight run. A plain `std::sync::RwLock` (not
75 /// `tokio::sync::RwLock`) — set once at boot via [`Engine::set_output_store`]
76 /// from a synchronous call site (`mlua-swarm-server`'s router builder),
77 /// then only ever briefly read (clone the `Option<Arc<..>>`, never held
78 /// across an `.await`) from the async submit path.
79 data_store: std::sync::RwLock<Option<Arc<dyn crate::store::output::OutputStore>>>,
80 /// GH #50 (Subtask 2 — runtime plumbing): agent name → declared
81 /// [`mlua_swarm_schema::VerdictContract`], the Engine-side registry
82 /// [`Self::verdict_contract_for_task`] resolves against. Populated via
83 /// [`Self::register_verdict_contracts`] — same sync-`RwLock`,
84 /// set-outside-the-lock idiom as `data_store` above. Empty by default
85 /// (every pre-GH-#50 `Engine`), which is exactly the opt-in "no
86 /// contract declared" state `verdict_contract_for_task` treats as
87 /// `None`. Populated from a live `Compiler::compile`'s
88 /// `CompiledAgentTable.verdict_contracts` output by
89 /// `TaskLaunchService::launch`, immediately after `compiler.compile`
90 /// succeeds — see [`Self::register_verdict_contracts`]'s doc for the
91 /// overwrite semantics of that merge.
92 verdict_contracts: std::sync::RwLock<HashMap<String, mlua_swarm_schema::VerdictContract>>,
93}
94
95/// Renders a `TaskSpec.initial_directive` / `EngineState.prompts`
96/// `Value` down to the `String` shape that string-consuming boundaries
97/// require (issue #18). Strings pass through verbatim; anything else
98/// (Object / Array / Number / Bool / Null) is serde-stringified. This
99/// is the single canonical rendering — the coercion that used to sit
100/// inside `EngineDispatcher::dispatch` moved here and is invoked only
101/// at consumer boundaries: `WorkerPayload.prompt` (HTTP
102/// `/v1/worker/prompt`), `WorkerInvocation.prompt` (in-process
103/// spawners), the subprocess spawner's directive arg/stdin, and the
104/// WS Spawn frame text render (`operator_ws::session`). Everything
105/// upstream (Blueprint dispatch → engine state → `fetch_prompt` →
106/// `Operator::execute`) keeps the `Value` end-to-end.
107pub(crate) fn render_directive_to_string(v: &Value) -> String {
108 match v {
109 Value::String(s) => s.clone(),
110 other => other.to_string(),
111 }
112}
113
114/// Renders a [`crate::worker::output::ContentRef`] down to the `Value` shape
115/// the BP-chain / `DispatchOutcome` consume. `Inline` passes its `value`
116/// through verbatim; `FileRef` is stringified into the same
117/// `{"file_ref", "mime", "size_hint"}` shape `materialize_final_submission`
118/// uses for its own file-materialize projection — one canonical
119/// stringification, not two independently-maintained copies (GH #36 ST1:
120/// shared by both the `Final`-pull and the `Artifact`-parts fold in
121/// [`Engine::dispatch_attempt_with`]'s doc).
122fn content_ref_to_value(content: crate::worker::output::ContentRef) -> Value {
123 match content {
124 crate::worker::output::ContentRef::Inline { value } => value,
125 crate::worker::output::ContentRef::FileRef {
126 path,
127 mime,
128 size_hint,
129 } => serde_json::json!({
130 "file_ref": path.to_string_lossy(),
131 "mime": mime,
132 "size_hint": size_hint,
133 }),
134 }
135}
136
137/// GH #51 — reduces a [`content_ref_to_value`] result down to the `String`
138/// shape the completion-time verdict-contract check compares against a
139/// declared `VerdictContract.values` token set. A `Value::String` unwraps
140/// to its raw contents (no surrounding JSON quotes) — this mirrors the
141/// pre-GH-#51 `check_verdict_contract` (`mlua-swarm-server`'s
142/// `worker.rs`), which always compared the raw submitted body string
143/// directly, never a JSON-stringified copy. Any OTHER `Value` shape
144/// (`Number` / `Object` / `Array` / `Bool` / `Null` — i.e. a `channel:
145/// "body"` contract whose completing value is not a string at all, or a
146/// `FileRef` content whose `content_ref_to_value` projection is an
147/// object) falls back to `Value::to_string()`'s JSON-encoded form: it can
148/// never collide with a plain declared token like `"PASS"`, so it
149/// naturally fails membership — consistent with the "non-string values
150/// under a body contract are violations" rule (issue #51's Proposal).
151fn content_ref_to_comparable_string(content: crate::worker::output::ContentRef) -> String {
152 let value = content_ref_to_value(content);
153 match value {
154 Value::String(s) => s,
155 other => other.to_string(),
156 }
157}
158
159/// [`Engine::dispatch_attempt_with`]'s Final-pull assembly (GH #36 ST1:
160/// named multi-part worker output), factored out as a pure function of the
161/// output-event tail so it is unit-testable without a live `Engine` /
162/// spawner.
163///
164/// Finds the LAST `Final` event in `tail` (mirrors the pre-GH-#36 pull:
165/// "last Final wins" if more than one was ever appended) and folds every
166/// `Artifact` event in the SAME tail WHOSE NAME APPEARS IN `staged_names`
167/// into a `"parts"` object keyed by `Artifact.name` — walked in tail (=
168/// event-append) order, so a name staged more than once within the attempt
169/// is last-write-wins (`Map` insert semantics, not an accumulating list;
170/// `Engine::stage_worker_artifact_trusted`'s doc). `staged_names` is the
171/// WORKER's own opt-in allowlist (`EngineState.worker_artifact_names`'s
172/// doc) — an `Artifact` on the tail whose name is NOT in `staged_names`
173/// (e.g. `AfterRunAuditMiddleware`'s `"audit:<step_ref>"` sidecar finding)
174/// is left alone, exactly as before GH #36; this is what keeps an audited
175/// step's BP-chain value byte-identical when the worker itself never
176/// staged a part.
177///
178/// At least one matching part: the returned value is `{"out": <final
179/// value>, "parts": {<name>: <value>, ...}}`. Zero matching parts: the
180/// returned value is the plain final value, unchanged from the pre-GH-#36
181/// shape — this is the back-compat guarantee, not an incidental default.
182///
183/// `None` when `tail` carries no `Final` at all (the caller's pre-existing
184/// "no Final in output_tail" error path).
185fn fold_final_and_parts(
186 tail: &[crate::worker::output::OutputEvent],
187 staged_names: &[String],
188) -> Option<(Value, bool)> {
189 let (final_content, ok) = tail.iter().rev().find_map(|ev| match ev {
190 crate::worker::output::OutputEvent::Final { content, ok } => Some((content.clone(), *ok)),
191 _ => None,
192 })?;
193 let final_value = content_ref_to_value(final_content);
194
195 let mut parts = serde_json::Map::new();
196 for ev in tail {
197 if let crate::worker::output::OutputEvent::Artifact { name, content } = ev {
198 if staged_names.iter().any(|staged| staged == name) {
199 parts.insert(name.clone(), content_ref_to_value(content.clone()));
200 }
201 }
202 }
203
204 let value = if parts.is_empty() {
205 final_value
206 } else {
207 serde_json::json!({ "out": final_value, "parts": Value::Object(parts) })
208 };
209 Some((value, ok))
210}
211
212impl Engine {
213 /// Backwards-compatible constructor that starts the engine without a
214 /// layer registry, preserving the signature already used by ~88
215 /// existing call sites. Use this when automatic middleware wrapping
216 /// at bind time is not needed. Callers such as `mlua-swarm-server` go through
217 /// `new_with_layers(cfg, registry)` to enable the hint-resolution path.
218 pub fn new(cfg: EngineCfg) -> Self {
219 Self::new_with_layers(cfg, crate::middleware::LayerRegistry::new())
220 }
221
222 /// Construct an `Engine` with an explicit `LayerRegistry`, enabling
223 /// hint-resolution: `spawner_hints.layers` declared on a `Blueprint`
224 /// are resolved against this registry when the spawner stack is bound
225 /// at `service::linker::link` time.
226 pub fn new_with_layers(
227 cfg: EngineCfg,
228 layer_registry: crate::middleware::LayerRegistry,
229 ) -> Self {
230 let (event_tx, _) = broadcast::channel(256);
231 let signer = TokenSigner::new(&cfg.token_secret);
232 Self {
233 inner: Arc::new(EngineInner {
234 state: Mutex::new(EngineState::new()),
235 cfg,
236 signer,
237 gate: default_role_verb_table(),
238 event_tx,
239 senior_bridges: tokio::sync::RwLock::new(HashMap::new()),
240 spawn_hooks: tokio::sync::RwLock::new(HashMap::new()),
241 operators: tokio::sync::RwLock::new(HashMap::new()),
242 layer_registry,
243 data_store: std::sync::RwLock::new(None),
244 verdict_contracts: std::sync::RwLock::new(HashMap::new()),
245 }),
246 }
247 }
248
249 /// Rebuild this `Engine` with a different `RoleVerbGate`. The gate is
250 /// treated as fixed-at-build-time, so this constructs a fresh
251 /// `EngineInner` (fresh empty `EngineState`) rather than mutating in
252 /// place — mainly a testing convenience for swapping gate rules.
253 pub fn with_gate(self, gate: RoleVerbGate) -> Self {
254 // The gate is fixed at build time — the intent is to build a fresh
255 // instance rather than mutating in place. As a testing convenience we
256 // do allow swapping the inner Arc. Simpler form: just rebuild
257 // Arc<EngineInner>.
258 let inner = Arc::new(EngineInner {
259 state: Mutex::new(EngineState::new()),
260 cfg: self.inner.cfg.clone(),
261 signer: self.inner.signer.clone(),
262 gate,
263 event_tx: self.inner.event_tx.clone(),
264 senior_bridges: tokio::sync::RwLock::new(HashMap::new()),
265 spawn_hooks: tokio::sync::RwLock::new(HashMap::new()),
266 operators: tokio::sync::RwLock::new(HashMap::new()),
267 layer_registry: self.inner.layer_registry.clone(),
268 data_store: std::sync::RwLock::new(None),
269 verdict_contracts: std::sync::RwLock::new(HashMap::new()),
270 });
271 Self { inner }
272 }
273
274 // ═══════════════════════════════════════════════════════════════════════
275 // Accessors. Production code drives execution through compile +
276 // `service::linker::link` + `dispatch_attempt_with(spawner)` inside
277 // `TaskLaunchService`; `Engine` itself is a pure execution surface — it
278 // does not own a BlueprintStore / EnhanceAdapter / Compiler, nor a
279 // global spawner (the spawner is carried per-request, never stashed on
280 // the engine).
281 // ═══════════════════════════════════════════════════════════════════════
282
283 /// Access the `EngineCfg` this engine was built with.
284 pub fn cfg(&self) -> &EngineCfg {
285 &self.inner.cfg
286 }
287
288 /// Expose the internal `LayerRegistry` — used when deriving a
289 /// sub-engine that needs the same registry re-injected. The
290 /// per-request sub-engine in `mlua-swarm-server` reads the parent engine's
291 /// registry through this accessor and passes it to
292 /// `Engine::new_with_layers(cfg, parent.layer_registry().clone())`.
293 pub fn layer_registry(&self) -> &crate::middleware::LayerRegistry {
294 &self.inner.layer_registry
295 }
296
297 /// Access the `TokenSigner` used to mint/verify `CapToken`s.
298 pub fn signer(&self) -> &TokenSigner {
299 &self.inner.signer
300 }
301
302 /// Clone a handle to the process-wide `Event` broadcast sender. Prefer
303 /// `subscribe` for a ready-to-use receiver.
304 pub fn event_tx(&self) -> broadcast::Sender<Event> {
305 self.inner.event_tx.clone()
306 }
307
308 /// Subscribe to the engine's `Event` broadcast stream.
309 pub fn subscribe(&self) -> EventStream {
310 self.inner.event_tx.subscribe()
311 }
312
313 /// Wires the Data-plane [`crate::store::output::OutputStore`] backend
314 /// used by `submit_output` / `submit_worker_result_trusted`'s
315 /// submit-time projection sink (subtask-4 / ST2 rework — see
316 /// `submit_output`'s doc). Synchronous (a plain `std::sync::RwLock`
317 /// write) so a caller can wire it up at boot from a non-`async`
318 /// context (`mlua-swarm-server`'s router builder passes the same
319 /// `Arc` it hands to its `AppState.data_store`, so `POST
320 /// /v1/data/emit` and every worker's ordinary `/v1/worker/submit` land
321 /// in the one store). Calling this more than once replaces the
322 /// previous backend; not calling it at all (the default) preserves
323 /// pre-subtask-4 behavior exactly — `submit_output` only touches the
324 /// Domain-plane `EngineState.output_store` HashMap.
325 pub fn set_output_store(&self, store: Arc<dyn crate::store::output::OutputStore>) {
326 let mut guard = self
327 .inner
328 .data_store
329 .write()
330 .unwrap_or_else(|poisoned| poisoned.into_inner());
331 *guard = Some(store);
332 }
333
334 /// Clones the currently-wired Data-plane store handle, if any. Kept
335 /// private and side-effect-free (no lock held past this call) —
336 /// callers (`materialize_final_submission`) do their actual `.append`
337 /// work outside of any lock.
338 fn output_store_backend(&self) -> Option<Arc<dyn crate::store::output::OutputStore>> {
339 self.inner
340 .data_store
341 .read()
342 .unwrap_or_else(|poisoned| poisoned.into_inner())
343 .clone()
344 }
345
346 /// GH #50 (Subtask 2): merges `contracts` (agent name → declared
347 /// [`mlua_swarm_schema::VerdictContract`]) into the engine's runtime
348 /// verdict-contract registry, later resolved per-task by
349 /// [`Self::verdict_contract_for_task`]. Same sync-write idiom as
350 /// [`Self::set_output_store`] — a plain `std::sync::RwLock` write, so
351 /// this can be called from a non-`async` context. Production call
352 /// site: `TaskLaunchService::launch`, immediately after a successful
353 /// `Compiler::compile`, passing `compiled.router.verdict_contracts.clone()`.
354 ///
355 /// # Overwrite semantics (explicit — read before adding a second call site)
356 ///
357 /// The registry is a single flat `HashMap` **keyed by agent name only**
358 /// (`String`), with process-wide (not per-task, not per-Blueprint,
359 /// not per-launch) scope. Registration is additive via
360 /// `HashMap::extend`: an entry for an agent name NOT already present is
361 /// added; an entry for an agent name ALREADY present is REPLACED
362 /// (last write wins) by the incoming one. Concretely: launching a
363 /// second Blueprint that also declares a `verdict` contract for an
364 /// agent named `"gate"` OVERWRITES whatever contract a first, still
365 /// in-flight, launch registered for an agent of that same name — even
366 /// if the two Blueprints intend it as two semantically different
367 /// agents that merely share a name, and even while the first launch's
368 /// tasks are still running. This is a **known limitation** of the v1
369 /// design; a per-task (or per-`RunId` / per-Blueprint) scoped registry
370 /// is a possible follow-up if two concurrently in-flight Blueprints
371 /// declaring conflicting contracts under the same agent name turns out
372 /// to matter in practice. Calling this with an empty map (or not at
373 /// all — the default) is a no-op, preserving pre-GH-#50 behavior
374 /// exactly (opt-in).
375 pub fn register_verdict_contracts(
376 &self,
377 contracts: HashMap<String, mlua_swarm_schema::VerdictContract>,
378 ) {
379 let mut guard = self
380 .inner
381 .verdict_contracts
382 .write()
383 .unwrap_or_else(|poisoned| poisoned.into_inner());
384 guard.extend(contracts);
385 }
386
387 /// GH #50 (Subtask 2): the declared
388 /// [`mlua_swarm_schema::VerdictContract`] for the agent currently
389 /// running `task_id`, if any. Resolves `task_id` → `TaskState.spec.agent`
390 /// (via `EngineState.tasks`, the same lookup [`Self::task_attempt`]
391 /// performs) and looks that agent name up in the registry
392 /// [`Self::register_verdict_contracts`] populates.
393 ///
394 /// `None` in both of these cases — deliberately collapsed to the same
395 /// value, mirroring [`Self::agent_context_for`]'s `Result`-into-`Option`
396 /// pattern (`.ok().flatten()`; a lookup failure here is never itself an
397 /// error worth surfacing to a caller):
398 /// - `task_id` is unknown (no `TaskState` for it).
399 /// - `task_id` resolves to a known agent, but that agent declared no
400 /// `verdict` contract (the opt-in default).
401 ///
402 /// Callers (`mlua-swarm-server`'s `worker_submit` / `worker_artifact`)
403 /// treat every `None` identically: skip the submit-time verdict gate
404 /// entirely, preserving pre-GH-#50 behavior byte-for-byte.
405 pub async fn verdict_contract_for_task(
406 &self,
407 task_id: &StepId,
408 ) -> Option<mlua_swarm_schema::VerdictContract> {
409 let tid = task_id.clone();
410 let agent = self
411 .with_state("verdict_contract_for_task", move |s| {
412 s.tasks.get(&tid).map(|t| t.spec.agent.clone())
413 })
414 .await
415 .ok()
416 .flatten()?;
417 self.inner
418 .verdict_contracts
419 .read()
420 .unwrap_or_else(|poisoned| poisoned.into_inner())
421 .get(&agent)
422 .cloned()
423 }
424
425 /// GH #51 — the value of the LAST staged `"verdict"` `Artifact` for
426 /// `(task_id, attempt)`, if any. Mirrors [`fold_final_and_parts`]'s
427 /// reverse-scan-of-`output_tail` pattern (last-write-wins per name,
428 /// same as that fold and [`Self::stage_worker_artifact_trusted`]'s
429 /// doc), narrowed to the single literal artifact name
430 /// `channel: "part"` contracts address (Pattern B — see
431 /// `blueprint-authoring.md`'s "Returning verdicts to drive BP flow").
432 ///
433 /// Infallible accessor: `None` is the normal "nothing staged yet"
434 /// case, not an error — the caller
435 /// ([`Self::verdict_contract_completion_check`]) is what converts
436 /// `None` into `Err(EngineError::VerdictPartMissing)`.
437 pub(crate) async fn staged_verdict_value_for(
438 &self,
439 task_id: &StepId,
440 attempt: u32,
441 ) -> Option<String> {
442 let tail = self.output_tail(task_id, attempt).await;
443 tail.iter().rev().find_map(|ev| match ev {
444 crate::worker::output::OutputEvent::Artifact { name, content } if name == "verdict" => {
445 Some(content_ref_to_comparable_string(content.clone()))
446 }
447 _ => None,
448 })
449 }
450
451 /// GH #51 — the single completion-time verdict-contract choke point,
452 /// embedded inside BOTH [`Self::submit_worker_result_trusted`] and
453 /// [`Self::submit_output`] (the two engine-side writes every HTTP/WS
454 /// completion route ultimately passes through). Not duplicated per
455 /// route handler — a future 4th completion route is gated for free
456 /// as long as it funnels through one of those two functions.
457 ///
458 /// `ok=false` is exempt on every route (this single early-return IS
459 /// the exemption, reused identically by both embedding sites — see
460 /// issue #51's "ok=false completions are exempt" acceptance
461 /// criterion). An agent with no declared contract, or a contract for
462 /// the OTHER channel, is untouched (`Ok(())`) — same opt-in,
463 /// byte-for-byte-preserving posture as
464 /// [`Self::verdict_contract_for_task`]'s doc.
465 ///
466 /// - `channel: "body"` — `value` (the completing `Final`'s content,
467 /// already reduced to a comparable string by the caller via
468 /// [`content_ref_to_comparable_string`]) must be a member of
469 /// `contract.values`.
470 /// - `channel: "part"` — [`Self::staged_verdict_value_for`] must find
471 /// a staged `"verdict"` artifact for this attempt (presence,
472 /// defense in depth over the staging-time membership check) AND its
473 /// value must be a member of `contract.values`.
474 async fn verdict_contract_completion_check(
475 &self,
476 task_id: &StepId,
477 attempt: u32,
478 ok: bool,
479 value: &str,
480 ) -> Result<(), EngineError> {
481 if !ok {
482 return Ok(());
483 }
484 let Some(contract) = self.verdict_contract_for_task(task_id).await else {
485 return Ok(());
486 };
487 match contract.channel {
488 mlua_swarm_schema::VerdictChannel::Body => {
489 if contract.values.iter().any(|v| v == value) {
490 Ok(())
491 } else {
492 Err(EngineError::VerdictValueRejected {
493 value: value.to_string(),
494 allowed: contract.values.clone(),
495 })
496 }
497 }
498 mlua_swarm_schema::VerdictChannel::Part => {
499 match self.staged_verdict_value_for(task_id, attempt).await {
500 None => Err(EngineError::VerdictPartMissing {
501 allowed: contract.values.clone(),
502 }),
503 Some(staged) if contract.values.iter().any(|v| v == &staged) => Ok(()),
504 Some(staged) => Err(EngineError::VerdictValueRejected {
505 value: staged,
506 allowed: contract.values.clone(),
507 }),
508 }
509 }
510 }
511 }
512
513 // ═══════════════════════════════════════════════════════════════════════
514 // §7 with_state — single Mutex + R1-R4 (try_lock + bounded retry + max-hold panic)
515 // ═══════════════════════════════════════════════════════════════════════
516
517 /// The closure is a **sync** `FnOnce` — you cannot pass an async
518 /// closure, which enforces R3 at the type level. Exceeding `max_hold`
519 /// panics so that R4 violations surface immediately.
520 pub async fn with_state<F, R>(&self, op: &'static str, f: F) -> Result<R, EngineError>
521 where
522 F: FnOnce(&mut EngineState) -> R,
523 {
524 let cfg = &self.inner.cfg;
525
526 // R2: try_lock + bounded retry
527 let mut guard_opt = None;
528 for attempt in 0..=cfg.max_retry {
529 match self.inner.state.try_lock() {
530 Ok(g) => {
531 guard_opt = Some(g);
532 break;
533 }
534 Err(_) if cfg.try_only => return Err(EngineError::LockBusy(op)),
535 Err(_) => {
536 let backoff = cfg.backoff_ms_step * (attempt as u64 + 1);
537 tokio::time::sleep(Duration::from_millis(backoff)).await;
538 }
539 }
540 }
541 let mut guard = guard_opt.ok_or(EngineError::LockBusyAfterRetry(op))?;
542
543 // R4: max_hold guard
544 let start = Instant::now();
545 let result = f(&mut guard);
546 let elapsed_ms = start.elapsed().as_millis();
547 drop(guard);
548
549 if elapsed_ms > cfg.max_hold_ms {
550 panic!(
551 "Engine.with_state('{op}') held {elapsed_ms}ms > max {}ms — suspected R3 violation (long op inside lock)",
552 cfg.max_hold_ms
553 );
554 }
555 Ok(result)
556 }
557
558 // ═══════════════════════════════════════════════════════════════════════
559 // Token verify (= sig + expire + gate + uses_left)
560 // ═══════════════════════════════════════════════════════════════════════
561
562 /// Four steps: (1) signature verify, (2) expiry check, (3) role × verb
563 /// gate, (4) `uses_left` consume.
564 pub async fn verify_token(&self, token: &CapToken, verb: Verb) -> Result<(), EngineError> {
565 // (1) sig
566 if !self.inner.signer.verify_sig(token) {
567 return Err(EngineError::BadSignature);
568 }
569 // (2) expire
570 if token.is_expired(now_unix()) {
571 return Err(EngineError::TokenExpired);
572 }
573 // (3) role × verb gate
574 if !self.inner.gate.is_allowed(token.role, verb) {
575 return Err(EngineError::RoleViolation {
576 role: token.role,
577 verb,
578 });
579 }
580 // (4) server-side uses_left consume
581 let fp = token.fingerprint();
582 self.with_state("token.consume", move |s| {
583 let rec = s
584 .tokens
585 .get_mut(&fp)
586 .ok_or_else(|| EngineError::TokenNotFound(fp.clone()))?;
587 rec.consume()
588 .map_err(|_: crate::core::state::CapTokenConsumeError| {
589 EngineError::TokenUsesExhausted
590 })?;
591 Ok::<(), EngineError>(())
592 })
593 .await??;
594 Ok(())
595 }
596
597 /// `verify_token` plus the **task-ownership gate**.
598 ///
599 /// When a Worker-role token calls a state-touch verb (`fetch_prompt` /
600 /// `post_result` / `read_task_state` / `cancel_task` / `poll_task`),
601 /// the gate checks that `CapTokenRecord.task_id` matches the argument
602 /// `task_id`; a mismatch returns `EngineError::TokenTaskMismatch`.
603 /// Operator / Senior / Observer tokens are outside the ownership gate
604 /// and may touch any task.
605 ///
606 /// **Verbs exempt from the gate.** `start_task` and `dispatch_attempt`
607 /// stay outside so recursive swarming keeps working; depth is capped
608 /// by `max_spawn_depth`.
609 pub async fn verify_token_for_task(
610 &self,
611 token: &CapToken,
612 verb: Verb,
613 task_id: &StepId,
614 ) -> Result<(), EngineError> {
615 self.verify_token(token, verb).await?;
616 if token.role != Role::Worker {
617 return Ok(());
618 }
619 let fp = token.fingerprint();
620 let arg_tid = task_id.clone();
621 self.with_state("token.ownership_gate", move |s| {
622 let bound = s.tokens.get(&fp).and_then(|r| r.task_id.as_ref()).cloned();
623 match bound {
624 Some(t) if t == arg_tid => Ok(()),
625 Some(t) => Err(EngineError::TokenTaskMismatch {
626 bound: t.into_string(),
627 arg: arg_tid.into_string(),
628 }),
629 None => Err(EngineError::TokenNotFound(fp.clone())),
630 }
631 })
632 .await??;
633 Ok(())
634 }
635
636 /// Resolve the bound `task_id` from a Worker-role token. Used on the
637 /// simple `/v1/worker/submit` endpoint, where the worker POSTs with a
638 /// token but no `task_id`. Returns `Err` if the token role is not
639 /// Worker, or if no bound task is set.
640 pub async fn task_id_from_token(&self, token: &CapToken) -> Result<StepId, EngineError> {
641 if token.role != Role::Worker {
642 return Err(EngineError::RoleViolation {
643 role: token.role,
644 verb: Verb::PostResult,
645 });
646 }
647 let fp = token.fingerprint();
648 self.with_state("task_id_from_token", move |s| {
649 s.tokens
650 .get(&fp)
651 .and_then(|r| r.task_id.as_ref())
652 .cloned()
653 .ok_or_else(|| EngineError::TokenNotFound(fp.clone()))
654 })
655 .await?
656 }
657
658 /// Resolve a short worker handle (`wh-XXXXXXXX`) to the bound
659 /// `task_id`. Used on `/v1/worker/submit` when the Bearer is a short
660 /// handle string rather than a full `CapToken` JSON. A missing entry
661 /// returns `TokenNotFound`, i.e. "the handle is not in the store".
662 pub async fn task_id_from_handle(&self, handle: &str) -> Result<StepId, EngineError> {
663 let h = handle.to_string();
664 self.with_state("task_id_from_handle", move |s| {
665 let fp = s
666 .worker_handles
667 .get(&h)
668 .cloned()
669 .ok_or_else(|| EngineError::TokenNotFound(format!("handle={h}")))?;
670 s.tokens
671 .get(&fp)
672 .and_then(|r| r.task_id.as_ref())
673 .cloned()
674 .ok_or_else(|| EngineError::TokenNotFound(format!("fp={fp}")))
675 })
676 .await?
677 }
678
679 /// Submit a worker result via a short handle. Skips token verification
680 /// and updates `output_tail` `Final` + `task.last_result` directly in
681 /// a thin path. The caller is expected to have already resolved
682 /// `task_id` via `task_id_from_handle` — the handle's presence in
683 /// `worker_handles` means it was minted server-side and is therefore
684 /// trusted.
685 ///
686 /// # GH #76 Skip tier: `outcome: SubmitOutcome`
687 ///
688 /// The `outcome` parameter (replacing the pre-#76 `ok: bool`) is the
689 /// caller's tier declaration:
690 ///
691 /// | outcome | `Final.ok` | `Final.content` | verdict-contract check |
692 /// |----------|------------|--------------------------------|------------------------|
693 /// | `Pass` | `true` | `value` verbatim | fires |
694 /// | `Blocked`| `false` | `value` verbatim | exempt (`ok=false`) |
695 /// | `Skip` | `true` | `wrap_skip_marker(value)` | exempt (Skip opt-out) |
696 ///
697 /// The Skip tier is opt-out from the verdict-contract completion check
698 /// on the same rationale [`crate::core::state::SubmitOutcome::Skip`]'s
699 /// doc records: the agent explicitly declared "not applicable", so
700 /// the payload is not a real verdict value to gate.
701 pub async fn submit_worker_result_trusted(
702 &self,
703 task_id: &StepId,
704 attempt: u32,
705 value: Value,
706 outcome: SubmitOutcome,
707 ) -> Result<(), EngineError> {
708 // Resolve outcome into the wire-level (value, ok, run_contract)
709 // triple exactly once, then reuse it below. Keeping the mapping
710 // literal in one place makes the "Skip wraps + skips contract"
711 // invariant grep-visible.
712 let (wire_value, wire_ok, run_contract_check) = match outcome {
713 SubmitOutcome::Pass => (value, true, true),
714 SubmitOutcome::Blocked => (value, false, false),
715 SubmitOutcome::Skip => (wrap_skip_marker(value), true, false),
716 };
717
718 // GH #51 — completion-time verdict-contract enforcement, embedded
719 // choke point 1 of 2 (see `Self::verdict_contract_completion_check`'s
720 // doc). This path always submits a `Final` by construction (there
721 // is no other event kind on `/v1/worker/submit`), so the check
722 // always applies — unlike `submit_output` below, no `if let
723 // OutputEvent::Final { .. }` guard is needed here since there is
724 // no other `OutputEvent` variant this function could be asked to
725 // write. Runs BEFORE the `output_tail` write immediately below:
726 // on `Err`, this returns immediately and neither `with_state` call
727 // in this function executes.
728 //
729 // GH #76 Skip tier: gated on `run_contract_check` — Skip is opt-out
730 // (see the outcome mapping table above), Blocked stays exempt via
731 // `verdict_contract_completion_check`'s existing `ok=false`
732 // early return (redundant flag here for grep locality).
733 if run_contract_check {
734 let comparable_value =
735 content_ref_to_comparable_string(crate::worker::output::ContentRef::Inline {
736 value: wire_value.clone(),
737 });
738 self.verdict_contract_completion_check(task_id, attempt, wire_ok, &comparable_value)
739 .await?;
740 }
741 let task_id_for_apply = task_id.clone();
742 let value_for_event = wire_value.clone();
743 self.with_state("submit_worker_result_trusted.output", move |s| {
744 let ev = crate::worker::output::OutputEvent::Final {
745 content: crate::worker::output::ContentRef::Inline {
746 value: value_for_event,
747 },
748 ok: wire_ok,
749 };
750 s.output_store
751 .entry((task_id_for_apply.clone(), attempt))
752 .or_default()
753 .push(ev.clone());
754 s.push_event(crate::core::state::Event::WorkerOutput {
755 task_id: task_id_for_apply,
756 attempt,
757 event: ev,
758 });
759 })
760 .await?;
761 let task_id_for_result = task_id.clone();
762 let value_for_result = wire_value.clone();
763 self.with_state("submit_worker_result_trusted.last_result", move |s| {
764 if let Some(t) = s.tasks.get_mut(&task_id_for_result) {
765 t.last_result = Some(value_for_result);
766 t.updated_at = now_unix();
767 }
768 })
769 .await?;
770 // subtask-4 / ST2 rework: this path always submits a `Final` (there
771 // is no other event kind on `/v1/worker/submit`), so the
772 // submit-time projection sink always fires — see
773 // `materialize_final_submission`'s doc and `submit_output`'s
774 // Invariants (fail-open, never turns a would-have-succeeded submit
775 // into a failure).
776 let content = crate::worker::output::ContentRef::Inline { value: wire_value };
777 self.materialize_final_submission(task_id, attempt, &content, wire_ok)
778 .await?;
779 Ok(())
780 }
781
782 /// Stage a named `Artifact` from a worker via a short handle (GH #36
783 /// ST1: named multi-part worker output). Trusted analog of
784 /// [`Self::submit_worker_result_trusted`] for `OutputEvent::Artifact`:
785 /// skips token verification for the same reason (the caller already
786 /// resolved `task_id` via `task_id_from_handle`, so the handle's
787 /// presence in `worker_handles` is itself the trust boundary).
788 ///
789 /// Appends to the same per-`(task_id, attempt)` `output_store` tail
790 /// [`Self::dispatch_attempt_with`]'s Final-pull later folds into
791 /// `{"out": <final>, "parts": {<name>: <value>, ...}}` (see that
792 /// method's doc for the fold semantics — event order, last-write-wins
793 /// per name), AND records `name` in `EngineState.worker_artifact_names`
794 /// — the fold's allowlist of the WORKER's own staged parts, as opposed
795 /// to every `Artifact` that happens to land on the shared tail (e.g. an
796 /// audit sidecar finding; see that field's doc). Also dual-writes to
797 /// the Data-plane `OutputStore` the same way [`Self::submit_output`]'s
798 /// `Artifact` arm does, via [`Self::materialize_artifact_submission`]
799 /// (the artifact's own `name` is its Data-plane key, no
800 /// canonicalization — see that method's doc).
801 pub async fn stage_worker_artifact_trusted(
802 &self,
803 task_id: &StepId,
804 attempt: u32,
805 name: String,
806 value: Value,
807 ) -> Result<(), EngineError> {
808 let content = crate::worker::output::ContentRef::Inline { value };
809 let task_id_for_apply = task_id.clone();
810 let name_for_apply = name.clone();
811 let content_for_apply = content.clone();
812 self.with_state("stage_worker_artifact_trusted.output", move |s| {
813 let ev = crate::worker::output::OutputEvent::Artifact {
814 name: name_for_apply.clone(),
815 content: content_for_apply,
816 };
817 s.output_store
818 .entry((task_id_for_apply.clone(), attempt))
819 .or_default()
820 .push(ev.clone());
821 s.worker_artifact_names
822 .entry((task_id_for_apply.clone(), attempt))
823 .or_default()
824 .push(name_for_apply);
825 s.push_event(crate::core::state::Event::WorkerOutput {
826 task_id: task_id_for_apply,
827 attempt,
828 event: ev,
829 });
830 })
831 .await?;
832 self.materialize_artifact_submission(task_id, attempt, &name, &content)
833 .await?;
834 Ok(())
835 }
836
837 /// GH #36 ST1: the set of `Artifact` names staged for `(task_id,
838 /// attempt)` via [`Self::stage_worker_artifact_trusted`] — see
839 /// `EngineState.worker_artifact_names`'s doc. Used by
840 /// [`Self::dispatch_attempt_with`]'s Final-pull to distinguish a
841 /// worker's own named parts from any other `Artifact` producer on the
842 /// same tail.
843 async fn worker_artifact_names_for(&self, task_id: &StepId, attempt: u32) -> Vec<String> {
844 let key = (task_id.clone(), attempt);
845 self.with_state("worker_artifact_names_for", move |s| {
846 s.worker_artifact_names
847 .get(&key)
848 .cloned()
849 .unwrap_or_default()
850 })
851 .await
852 .unwrap_or_default()
853 }
854
855 /// Mint a short handle and register it in the `worker_handles` map.
856 /// Called immediately after the worker-token mint inside
857 /// `dispatch_attempt_with`, and issues a handle bound to the same
858 /// token fingerprint. Format is `wh-<8 hex chars>` (11 chars total),
859 /// designed to remove the base64 copy-paste failure mode.
860 async fn mint_worker_handle(&self, worker_fp: String) -> Result<String, EngineError> {
861 // The handle is a sole bearer secret on the `/v1/worker/submit`
862 // short-handle path (`submit_worker_result_trusted` skips token
863 // verification), so it must be unguessable — OS RNG, not the
864 // predictable uid counter. 8 hex chars (~4B entropy) keeps the
865 // documented `wh-<8 hex>` wire shape; collision between live
866 // handles is negligible at in-process handle counts.
867 let short = crate::types::secure_hex(4);
868 let handle = format!("wh-{short}");
869 let h = handle.clone();
870 self.with_state("mint_worker_handle", move |s| {
871 s.worker_handles.insert(h, worker_fp);
872 })
873 .await?;
874 Ok(handle)
875 }
876
877 // ═══════════════════════════════════════════════════════════════════════
878 // Session API
879 // ═══════════════════════════════════════════════════════════════════════
880
881 /// Attach a new session with default `OperatorInfo` (`Automate`, no
882 /// bridges/hooks). Shorthand for `attach_with(.., OperatorInfo::default())`.
883 pub async fn attach(
884 &self,
885 operator_id: impl Into<String>,
886 role: Role,
887 ttl: Duration,
888 ) -> Result<CapToken, EngineError> {
889 self.attach_with(
890 operator_id,
891 role,
892 ttl,
893 crate::core::ctx::OperatorInfo::default(),
894 )
895 .await
896 }
897
898 // ═══════════════════════════════════════════════════════════════════════
899 // BridgeRegistry API.
900 // ═══════════════════════════════════════════════════════════════════════
901
902 /// Register a `SeniorBridge` under a name. An existing entry with the
903 /// same name is overwritten. On the persisted-session reattach path,
904 /// the caller re-registers under the same ID beforehand and the
905 /// bridge becomes effective again.
906 pub async fn register_senior_bridge(
907 &self,
908 id: impl Into<String>,
909 bridge: Arc<dyn SeniorBridge>,
910 ) {
911 self.inner
912 .senior_bridges
913 .write()
914 .await
915 .insert(id.into(), bridge);
916 }
917
918 /// Register a `SpawnHook` under a name. An existing entry with the
919 /// same name is overwritten.
920 pub async fn register_spawn_hook(&self, id: impl Into<String>, hook: Arc<dyn SpawnHook>) {
921 self.inner.spawn_hooks.write().await.insert(id.into(), hook);
922 }
923
924 /// Register an `Operator` (a spawn-body backend) under a name. An
925 /// existing entry with the same name is overwritten.
926 /// `OperatorDelegateMiddleware` looks this up via `ctx` and, when
927 /// `kind = MainAi` / `Composite`, bypasses `inner.spawn` and calls
928 /// `operator.execute` instead.
929 pub async fn register_operator(
930 &self,
931 id: impl Into<String>,
932 operator: Arc<dyn crate::operator::Operator>,
933 ) {
934 self.inner
935 .operators
936 .write()
937 .await
938 .insert(id.into(), operator);
939 }
940
941 /// Unregister a `SeniorBridge` by name (e.g. on WebSocket disconnect
942 /// or explicit teardown). A missing ID is a no-op.
943 pub async fn unregister_senior_bridge(&self, id: &str) {
944 self.inner.senior_bridges.write().await.remove(id);
945 }
946
947 /// Unregister a `SpawnHook` by name. A missing ID is a no-op.
948 pub async fn unregister_spawn_hook(&self, id: &str) {
949 self.inner.spawn_hooks.write().await.remove(id);
950 }
951
952 /// Unregister an `Operator` backend by name. A missing ID is a no-op.
953 pub async fn unregister_operator(&self, id: &str) {
954 self.inner.operators.write().await.remove(id);
955 }
956
957 /// Snapshot the list of registered `SpawnHook` IDs (for test
958 /// observation and debugging).
959 pub async fn list_spawn_hook_ids(&self) -> Vec<String> {
960 self.inner
961 .spawn_hooks
962 .read()
963 .await
964 .keys()
965 .cloned()
966 .collect()
967 }
968
969 /// Snapshot the list of registered `SeniorBridge` IDs.
970 pub async fn list_senior_bridge_ids(&self) -> Vec<String> {
971 self.inner
972 .senior_bridges
973 .read()
974 .await
975 .keys()
976 .cloned()
977 .collect()
978 }
979
980 /// Snapshot the list of registered `Operator` IDs.
981 pub async fn list_operator_ids(&self) -> Vec<String> {
982 self.inner.operators.read().await.keys().cloned().collect()
983 }
984
985 /// Attach specifying IDs directly. The caller is expected to have
986 /// pre-registered them via `register_senior_bridge` /
987 /// `register_spawn_hook` / `register_operator`. This is the canonical
988 /// path when persistence is in play.
989 ///
990 /// `kind` is the "Runtime Global" tier of the `OperatorKind` cascade
991 /// (stored verbatim on `OperatorSession.operator_kind`): `Some(_)` is
992 /// an explicit request (including `Some(OperatorKind::Automate)`) that
993 /// outranks the BP-level tiers; `None` leaves it unspecified so the
994 /// BP-level tiers / final default decide. See
995 /// `crate::core::ctx::collapse_operator_kind`.
996 #[allow(clippy::too_many_arguments)]
997 pub async fn attach_with_ids(
998 &self,
999 operator_id: impl Into<String>,
1000 role: Role,
1001 ttl: Duration,
1002 kind: Option<OperatorKind>,
1003 bridge_id: Option<String>,
1004 hook_id: Option<String>,
1005 operator_backend_id: Option<String>,
1006 operator_kind_overrides: HashMap<String, OperatorKind>,
1007 bp_agent_kinds: HashMap<String, OperatorKind>,
1008 bp_global_kind: Option<OperatorKind>,
1009 ) -> Result<CapToken, EngineError> {
1010 let operator_id = operator_id.into();
1011 let token = self
1012 .inner
1013 .signer
1014 .session(operator_id.clone(), role, vec!["*".into()], ttl);
1015 let session_id = SessionId::new();
1016 let fp = token.fingerprint();
1017 let now = now_unix();
1018 let token_for_store = token.clone();
1019
1020 self.with_state("attach_with_ids", |s| {
1021 s.tokens
1022 .insert(fp.clone(), CapTokenRecord::from_token(token_for_store));
1023 s.sessions.insert(
1024 session_id.clone(),
1025 OperatorSession {
1026 id: session_id.clone(),
1027 operator_id: operator_id.clone(),
1028 role,
1029 attached_at: now,
1030 last_seen: now,
1031 attached: true,
1032 owned_task_ids: Vec::new(),
1033 token_fp: fp.clone(),
1034 operator_kind: kind,
1035 runtime_agent_kinds: operator_kind_overrides,
1036 bp_agent_kinds,
1037 bp_global_kind,
1038 bridge_id,
1039 hook_id,
1040 operator_backend_id,
1041 },
1042 );
1043 s.push_event(Event::SessionAttached {
1044 session_id: session_id.clone(),
1045 role,
1046 });
1047 })
1048 .await?;
1049
1050 let _ = self
1051 .inner
1052 .event_tx
1053 .send(Event::SessionAttached { session_id, role });
1054 Ok(token)
1055 }
1056
1057 /// Build an `OperatorInfo` by looking up the session's registered IDs
1058 /// on the `BridgeRegistry`, plus resolving the 4-tier `OperatorKind`
1059 /// cascade for `agent_name` via `crate::core::ctx::collapse_operator_kind`.
1060 /// Used when `dispatch_attempt` injects `Ctx`. An unresolved ID
1061 /// (nothing registered) is silently `None` — the bridge / hook simply
1062 /// does not fire and the default behaviour applies.
1063 async fn resolve_operator_info(
1064 &self,
1065 session: &OperatorSession,
1066 agent_name: &str,
1067 ) -> OperatorInfo {
1068 let senior_bridge = if let Some(id) = &session.bridge_id {
1069 self.inner.senior_bridges.read().await.get(id).cloned()
1070 } else {
1071 None
1072 };
1073 let spawn_hook = if let Some(id) = &session.hook_id {
1074 self.inner.spawn_hooks.read().await.get(id).cloned()
1075 } else {
1076 None
1077 };
1078 let operator = if let Some(id) = &session.operator_backend_id {
1079 self.inner.operators.read().await.get(id).cloned()
1080 } else {
1081 None
1082 };
1083 let runtime_agent = session.runtime_agent_kinds.get(agent_name).copied();
1084 // "Runtime Global" tier: `Some(_)` is always an explicit request
1085 // (see the field doc on `OperatorSession.operator_kind`).
1086 let runtime_global = session.operator_kind;
1087 let bp_agent = session.bp_agent_kinds.get(agent_name).copied();
1088 let bp_global = session.bp_global_kind;
1089 let kind = crate::core::ctx::collapse_operator_kind(
1090 runtime_agent,
1091 runtime_global,
1092 bp_agent,
1093 bp_global,
1094 );
1095 OperatorInfo {
1096 kind,
1097 id: session.operator_id.clone(),
1098 senior_bridge,
1099 spawn_hook,
1100 operator,
1101 }
1102 }
1103
1104 /// Convenience attach that takes an `OperatorInfo` (three
1105 /// `Arc<dyn ...>` fields plus `kind`) **inline**.
1106 ///
1107 /// # Pipeline
1108 ///
1109 /// Each `Arc<dyn ...>` is auto-registered on the engine's registry
1110 /// under a synthetic ID (`br-<hex>` / `hk-<hex>` / `ob-<hex>`), and
1111 /// the session stores that synthetic ID. Subsequent `dispatch_attempt`
1112 /// calls rebuild the `Arc`s from those IDs via
1113 /// `resolve_operator_info`, and the three middlewares fire as usual.
1114 ///
1115 /// # ⚠ Non-persisted sessions only
1116 ///
1117 /// Because this API takes inline `Arc`s, the reattach path after
1118 /// session persistence cannot rebuild them — the synthetic IDs are
1119 /// not present in a freshly started process's registry. If you need
1120 /// persistence, use [`Self::attach_with_ids`] with `register_*` calls
1121 /// beforehand to go through **named IDs** instead.
1122 ///
1123 /// Handy for tests and short-lived in-process sessions. Production
1124 /// WebSocket callbacks and the like should prefer `attach_with_ids`
1125 /// as the canonical path.
1126 pub async fn attach_with(
1127 &self,
1128 operator_id: impl Into<String>,
1129 role: Role,
1130 ttl: Duration,
1131 operator_info: crate::core::ctx::OperatorInfo,
1132 ) -> Result<CapToken, EngineError> {
1133 let operator_id = operator_id.into();
1134 // The caller always hands in a fully-formed `OperatorInfo`
1135 // (including its `kind`), so it is stored as an explicit "Runtime
1136 // Global" tier request (`Some(kind)`) — this path never persists
1137 // BP-level tiers (both stay empty below), so `Some(kind)` resolves
1138 // to the same `kind` at dispatch either way; see
1139 // `OperatorSession.operator_kind` doc.
1140 let kind = operator_info.kind;
1141 // BridgeRegistry auto-register: when the caller hands in an
1142 // `Arc<dyn>` directly, register it under a synthesised ID (the inline
1143 // path aware of persistence). Callers who want to pre-register with a
1144 // named ID should use `register_senior_bridge` / `register_spawn_hook`
1145 // + `attach_with_ids`.
1146 let bridge_id = if let Some(bridge) = operator_info.senior_bridge.clone() {
1147 let id = format!("br-{}", crate::types::uid_hex(8));
1148 self.inner
1149 .senior_bridges
1150 .write()
1151 .await
1152 .insert(id.clone(), bridge);
1153 Some(id)
1154 } else {
1155 None
1156 };
1157 let hook_id = if let Some(hook) = operator_info.spawn_hook.clone() {
1158 let id = format!("hk-{}", crate::types::uid_hex(8));
1159 self.inner
1160 .spawn_hooks
1161 .write()
1162 .await
1163 .insert(id.clone(), hook);
1164 Some(id)
1165 } else {
1166 None
1167 };
1168 let operator_backend_id = if let Some(operator) = operator_info.operator.clone() {
1169 // `ob-` = operator-backend registry id. Renamed from `op-` in the
1170 // issue #11 prefix reconciliation: `op-` used to collide with the
1171 // WS operator sid shape (now unified into `S-<hex>` anyway), and a
1172 // shared prefix across two unrelated registries made log filtering
1173 // by prefix silently ambiguous.
1174 let id = format!("ob-{}", crate::types::uid_hex(8));
1175 self.inner
1176 .operators
1177 .write()
1178 .await
1179 .insert(id.clone(), operator);
1180 Some(id)
1181 } else {
1182 None
1183 };
1184
1185 let token = self
1186 .inner
1187 .signer
1188 .session(operator_id.clone(), role, vec!["*".into()], ttl);
1189 let session_id = SessionId::new();
1190 let fp = token.fingerprint();
1191 let now = now_unix();
1192 let token_for_store = token.clone();
1193
1194 self.with_state("attach_with", |s| {
1195 s.tokens
1196 .insert(fp.clone(), CapTokenRecord::from_token(token_for_store));
1197 s.sessions.insert(
1198 session_id.clone(),
1199 OperatorSession {
1200 id: session_id.clone(),
1201 operator_id,
1202 role,
1203 attached_at: now,
1204 last_seen: now,
1205 attached: true,
1206 owned_task_ids: Vec::new(),
1207 token_fp: fp.clone(),
1208 operator_kind: Some(kind),
1209 runtime_agent_kinds: HashMap::new(),
1210 bp_agent_kinds: HashMap::new(),
1211 bp_global_kind: None,
1212 bridge_id,
1213 hook_id,
1214 operator_backend_id,
1215 },
1216 );
1217 s.push_event(Event::SessionAttached {
1218 session_id: session_id.clone(),
1219 role,
1220 });
1221 })
1222 .await?;
1223
1224 let _ = self
1225 .inner
1226 .event_tx
1227 .send(Event::SessionAttached { session_id, role });
1228 Ok(token)
1229 }
1230
1231 /// Mark the session bound to `token` as detached (`attached = false`).
1232 /// Tasks are left in place — a later `attach`/`attach_with_ids` call
1233 /// carrying the same registered bridge/hook IDs can pick them back up.
1234 pub async fn detach(&self, token: &CapToken) -> Result<(), EngineError> {
1235 self.verify_token(token, Verb::DetachSession).await?;
1236 let fp = token.fingerprint();
1237 self.with_state("detach", move |s| {
1238 let sid = s
1239 .sessions
1240 .iter()
1241 .find(|(_, sess)| sess.token_fp == fp)
1242 .map(|(id, _)| id.clone());
1243 if let Some(sid) = sid {
1244 if let Some(sess) = s.sessions.get_mut(&sid) {
1245 sess.attached = false;
1246 }
1247 s.push_event(Event::SessionDetached {
1248 session_id: sid.clone(),
1249 });
1250 let _ = sid;
1251 }
1252 })
1253 .await?;
1254 Ok(())
1255 }
1256
1257 /// Refresh the session's `last_seen` timestamp and mark it `attached`.
1258 /// Called periodically by an attached client to avoid being flipped to
1259 /// detached by `start_detach_loop`.
1260 pub async fn heartbeat(&self, token: &CapToken) -> Result<(), EngineError> {
1261 self.verify_token(token, Verb::Heartbeat).await?;
1262 let now = now_unix();
1263 let fp = token.fingerprint();
1264 self.with_state("heartbeat", move |s| {
1265 if let Some(sess) = s.sessions.values_mut().find(|sess| sess.token_fp == fp) {
1266 sess.last_seen = now;
1267 sess.attached = true;
1268 }
1269 })
1270 .await?;
1271 Ok(())
1272 }
1273
1274 // ═══════════════════════════════════════════════════════════════════════
1275 // Task lifecycle
1276 // ═══════════════════════════════════════════════════════════════════════
1277
1278 /// Create a new `TaskState` from `spec` and register its initial
1279 /// prompt. When the calling token is a Worker (i.e. this is a
1280 /// recursive spawn), the new task inherits `parent.spawn_depth + 1`
1281 /// and is rejected with `SpawnDepthExceeded` once `max_spawn_depth` is
1282 /// hit; an Operator-issued call starts at depth 0.
1283 pub async fn start_task(
1284 &self,
1285 token: &CapToken,
1286 spec: TaskSpec,
1287 ) -> Result<StepId, EngineError> {
1288 self.verify_token(token, Verb::StartTask).await?;
1289 let task_id = StepId::new();
1290 let initial_directive = spec.initial_directive.clone();
1291 let task_id_clone = task_id.clone();
1292 let fp = token.fingerprint();
1293 let max_depth = self.inner.cfg.max_spawn_depth;
1294 self.with_state("start_task", move |s| {
1295 // Recursive swarm depth gate (recursion guard):
1296 // Worker tokens carry CapTokenRecord.parent_task_id. Give the
1297 // child parent's spawn_depth + 1; if it exceeds `max`, raise an
1298 // error. Operator tokens (parent_task_id=None) start at depth 0.
1299 let parent_depth_opt = s
1300 .tokens
1301 .get(&fp)
1302 .and_then(|rec| rec.task_id.as_ref())
1303 .and_then(|tid| s.tasks.get(tid))
1304 .map(|t| t.spawn_depth);
1305 let depth = match parent_depth_opt {
1306 Some(d) => {
1307 if d + 1 >= max_depth {
1308 return Err(EngineError::SpawnDepthExceeded {
1309 current: d + 1,
1310 max: max_depth,
1311 });
1312 }
1313 d + 1
1314 }
1315 None => 0,
1316 };
1317
1318 let mut task = TaskState::new(task_id_clone.clone(), spec);
1319 task.spawn_depth = depth;
1320 s.tasks.insert(task_id_clone.clone(), task);
1321 s.prompts
1322 .insert((task_id_clone.clone(), 1), initial_directive);
1323 // Link to the owner session (only Operator tokens match; Worker tokens have no session).
1324 if let Some(sess) = s.sessions.values_mut().find(|sess| sess.token_fp == fp) {
1325 sess.owned_task_ids.push(task_id_clone.clone());
1326 }
1327 s.push_event(Event::TaskCreated {
1328 task_id: task_id_clone.clone(),
1329 });
1330 Ok::<(), EngineError>(())
1331 })
1332 .await??;
1333 let _ = self.inner.event_tx.send(Event::TaskCreated {
1334 task_id: task_id.clone(),
1335 });
1336 Ok(task_id)
1337 }
1338
1339 /// Fetch a snapshot of `TaskState` for `task_id`, subject to the
1340 /// task-ownership gate (see `verify_token_for_task`).
1341 pub async fn read_task_state(
1342 &self,
1343 token: &CapToken,
1344 task_id: &StepId,
1345 ) -> Result<TaskState, EngineError> {
1346 self.verify_token_for_task(token, Verb::ReadTaskState, task_id)
1347 .await?;
1348 let task_id = task_id.clone();
1349 self.with_state("read_task_state", move |s| {
1350 s.tasks
1351 .get(&task_id)
1352 .cloned()
1353 .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))
1354 })
1355 .await?
1356 }
1357
1358 /// Mark `task_id` as `Cancelled` and wake any caller blocked in
1359 /// `poll_task` for it.
1360 pub async fn cancel_task(&self, token: &CapToken, task_id: &StepId) -> Result<(), EngineError> {
1361 self.verify_token_for_task(token, Verb::CancelTask, task_id)
1362 .await?;
1363 let tid = task_id.clone();
1364 self.with_state("cancel_task", move |s| {
1365 let task = s
1366 .tasks
1367 .get_mut(&tid)
1368 .ok_or_else(|| EngineError::TaskNotFound(tid.to_string()))?;
1369 task.status = TaskStatus::Cancelled;
1370 task.updated_at = now_unix();
1371 s.push_event(Event::TaskCancelled {
1372 task_id: tid.clone(),
1373 });
1374 Ok::<(), EngineError>(())
1375 })
1376 .await??;
1377 self.wake_task(task_id).await?;
1378 Ok(())
1379 }
1380
1381 /// Dispatch a single attempt through the given `spawner`.
1382 ///
1383 /// The lock is only held for snapshot capture; the actual spawn and
1384 /// completion await happen outside the lock (R3 discipline).
1385 ///
1386 /// Sits on the Domain side of the Data / Domain split. The dispatch
1387 /// path itself does not touch big response bodies — those flow through
1388 /// the Data plane (`output_store` module + sink / input_inject
1389 /// `SpawnerLayer`s) around this method.
1390 ///
1391 /// The caller does the compile plus `service::linker::link` and
1392 /// carries the same stack through each dispatch. Because the spawner
1393 /// is passed per-request rather than looked up from engine-global
1394 /// state, parallel requests against a single `Engine` instance
1395 /// (different Blueprints, different spawners) do not race.
1396 ///
1397 /// `run_id`, when `Some` (issue #13 run_id propagation —
1398 /// `EngineDispatcher` threads it in from its `RunContext`), is
1399 /// inserted into `Ctx.meta.runtime["run_id"]` (a plain JSON string)
1400 /// alongside `worker_handle`, so `Operator::execute` implementations
1401 /// (e.g. `WSOperatorSession`) can read it back and surface it to the
1402 /// worker (Spawn directive / prompt). `None` (every pre-existing
1403 /// caller / test) omits the key entirely — unchanged behavior.
1404 pub async fn dispatch_attempt_with(
1405 &self,
1406 token: &CapToken,
1407 task_id: &StepId,
1408 spawner: &Arc<dyn SpawnerAdapter>,
1409 run_id: Option<&RunId>,
1410 ) -> Result<DispatchOutcome, EngineError> {
1411 self.verify_token(token, Verb::DispatchAttempt).await?;
1412 let task_id = task_id.clone();
1413
1414 // 1) Under the lock: increment the attempt number, mark Running, snapshot the
1415 // prompt, and pull `operator_info` from the session so we can inject it into Ctx.
1416 let fp = token.fingerprint();
1417 let tid_for_prep = task_id.clone();
1418 let (attempt, agent, session_snapshot, step_ctx) = self
1419 .with_state("dispatch.prep", move |s| {
1420 let task = s
1421 .tasks
1422 .get_mut(&tid_for_prep)
1423 .ok_or_else(|| EngineError::TaskNotFound(tid_for_prep.to_string()))?;
1424 task.attempt += 1;
1425 task.status = TaskStatus::Running;
1426 task.updated_at = now_unix();
1427 // The spawner pulls the prompt via engine.fetch_prompt. In prep,
1428 // if the prompts table has no entry for this attempt yet,
1429 // fall back and insert `initial_directive` so the subsequent
1430 // fetch_prompt succeeds.
1431 let attempt = task.attempt;
1432 let initial = task.spec.initial_directive.clone();
1433 s.prompts
1434 .entry((tid_for_prep.clone(), attempt))
1435 .or_insert(initial);
1436 let task = s
1437 .tasks
1438 .get(&tid_for_prep)
1439 .ok_or_else(|| EngineError::TaskNotFound(tid_for_prep.to_string()))?;
1440 let agent = task.spec.agent.clone();
1441 // GH #21 Phase 2: re-read `TaskSpec.step_ctx` on EVERY
1442 // attempt (not cached once at start_task) so retries and
1443 // Run-rekicks all carry the Step tier through to Ctx —
1444 // see TaskSpec.step_ctx's doc.
1445 let step_ctx = task.spec.step_ctx.clone();
1446 // Session snapshot (looked up by token nonce). When no session
1447 // exists (worker token invoked directly / test injection), fall
1448 // back to None → default OperatorInfo.
1449 let sess_clone = s
1450 .sessions
1451 .values()
1452 .find(|sess| sess.token_fp == fp)
1453 .cloned();
1454 Ok::<_, EngineError>((attempt, agent, sess_clone, step_ctx))
1455 })
1456 .await??;
1457 // BridgeRegistry lookup + per-agent OperatorKind cascade.
1458 let operator_info = match session_snapshot {
1459 Some(sess) => self.resolve_operator_info(&sess, &agent).await,
1460 None => OperatorInfo::default(),
1461 };
1462
1463 // 2) Outside the lock: worker token mint + spawn.
1464 //
1465 // Session-style mint (max_uses=None). Within one attempt the worker is
1466 // expected to hit `verify_token + fetch_prompt + fetch_data + post_result`
1467 // multiple times in order, so `one_time` would exhaust the token on the
1468 // very first verb. Capability is guarded by (a) the role × verb gate and
1469 // (b) the short TTL (1800s).
1470 let worker_token = self.inner.signer.session(
1471 format!("worker-of-{task_id}"),
1472 Role::Worker,
1473 vec!["*".into()],
1474 Duration::from_secs(1800),
1475 );
1476 let worker_fp = worker_token.fingerprint();
1477 let task_id_for_worker = task_id.clone();
1478 let worker_token_for_store = worker_token.clone();
1479 self.with_state("dispatch.mint_worker", move |s| {
1480 s.tokens.insert(
1481 worker_fp,
1482 CapTokenRecord::from_worker_token(worker_token_for_store, task_id_for_worker),
1483 );
1484 })
1485 .await?;
1486
1487 // Mint a short handle (`wh-XXXXXXXX`) and register it in worker_handles.
1488 // Used by the simplified Bearer path for SubAgents (short-handle form
1489 // avoids base64 copy-paste incidents).
1490 let worker_handle = self.mint_worker_handle(worker_token.fingerprint()).await?;
1491
1492 let mut ctx = Ctx::new(task_id.clone(), attempt, agent.clone());
1493 ctx.operator = operator_info; // activates MainAIMiddleware / Senior bridge
1494 ctx.meta
1495 .runtime
1496 .insert("worker_handle".to_string(), Value::String(worker_handle));
1497 if let Some(rid) = run_id {
1498 ctx.meta
1499 .runtime
1500 .insert(RUN_ID_KEY.to_string(), Value::String(rid.to_string()));
1501 }
1502 // GH #21 Phase 2: the Step tier's resolved context bundle (from
1503 // `TaskSpec.step_ctx`, re-read every attempt above) — consumed by
1504 // `AgentContextMiddleware`, which unpacks its keys ahead of the
1505 // Agent / BP-global tiers.
1506 if let Some(step_ctx) = step_ctx {
1507 ctx.meta.runtime.insert(STEP_CTX_KEY.to_string(), step_ctx);
1508 }
1509
1510 let worker = spawner
1511 .spawn(self, &ctx, task_id.clone(), attempt, worker_token)
1512 .await
1513 .map_err(|e| EngineError::DispatchFailed(e.to_string()))?;
1514
1515 // 3) Outside the lock: await worker.join() (signal-only). WorkerError is
1516 // stringified. The value is fetched via output_tail (sink path).
1517 let signal_result: Result<(), String> = worker.join().await.map_err(|e| e.to_string());
1518
1519 // Pull the last Final from output_tail and use it as the value. GH
1520 // #36 ST1 (named multi-part worker output): also fold every
1521 // `Artifact` the WORKER ITSELF staged on the same tail (via
1522 // `stage_worker_artifact_trusted` / `POST /v1/worker/artifact`)
1523 // into a `"parts"` object keyed by name — event order,
1524 // last-write-wins per name (a name staged twice overwrites,
1525 // mirroring `HashMap`/`Map` insert semantics, not an accumulating
1526 // list). `worker_artifact_names_for` is the allowlist that scopes
1527 // this to the worker's own opt-in parts — an `Artifact` some OTHER
1528 // producer appended to this same tail (e.g.
1529 // `AfterRunAuditMiddleware`'s `"audit:<step_ref>"` sidecar finding)
1530 // is left untouched (see `fold_final_and_parts`'s doc). When at
1531 // least one part was staged, the BP-chain value becomes `{"out":
1532 // <final value>, "parts": {...}}`; zero parts staged (the
1533 // pre-GH-#36 case, and every non-opt-in step) leaves the value
1534 // exactly the plain `Final` value, byte-identical to before this
1535 // change.
1536 let value_ok: Result<(Value, bool), String> = match signal_result {
1537 Ok(()) => {
1538 let tail = self.output_tail(&task_id, attempt).await;
1539 let staged_names = self.worker_artifact_names_for(&task_id, attempt).await;
1540 fold_final_and_parts(&tail, &staged_names)
1541 .ok_or_else(|| "no Final in output_tail".to_string())
1542 }
1543 Err(msg) => Err(msg),
1544 };
1545
1546 // 4) Under the lock: apply (split the borrow scope so push_event and task mut can co-exist).
1547 let outcome = self
1548 .with_state("dispatch.apply", |s| {
1549 if !s.tasks.contains_key(&task_id) {
1550 return Err(EngineError::TaskNotFound(task_id.to_string()));
1551 }
1552 match value_ok {
1553 Ok((value, ok)) => {
1554 // GH #76 Skip tier: a Final with ok=true carrying the
1555 // skip-marker sentinel is a Skip tier completion,
1556 // not an ordinary Pass. TaskStatus stays `Pass`
1557 // (the worker itself completed successfully);
1558 // the Skip signal rides on DispatchOutcome so
1559 // EngineDispatcher::dispatch can route it to
1560 // the flow-continuation-without-binding-write
1561 // sentinel path.
1562 let skip_inner = if ok { unwrap_skip_marker(&value) } else { None };
1563 let pass = ok;
1564 {
1565 let task = s.tasks.get_mut(&task_id).unwrap();
1566 task.last_result = Some(value.clone());
1567 task.updated_at = now_unix();
1568 task.status = if pass {
1569 TaskStatus::Pass
1570 } else {
1571 TaskStatus::Blocked
1572 };
1573 }
1574 s.push_event(Event::TaskAttemptCompleted {
1575 task_id: task_id.clone(),
1576 attempt,
1577 result: value.clone(),
1578 });
1579 if let Some(inner) = skip_inner {
1580 s.push_event(Event::TaskPass {
1581 task_id: task_id.clone(),
1582 result: value.clone(),
1583 });
1584 Ok::<_, EngineError>(DispatchOutcome::Skip(inner))
1585 } else if pass {
1586 s.push_event(Event::TaskPass {
1587 task_id: task_id.clone(),
1588 result: value.clone(),
1589 });
1590 Ok::<_, EngineError>(DispatchOutcome::Pass(value))
1591 } else {
1592 s.push_event(Event::TaskBlocked {
1593 task_id: task_id.clone(),
1594 result: value.clone(),
1595 });
1596 Ok(DispatchOutcome::Blocked(value))
1597 }
1598 }
1599 Err(msg) => {
1600 let task = s.tasks.get_mut(&task_id).unwrap();
1601 task.status = TaskStatus::Blocked;
1602 task.updated_at = now_unix();
1603 Err(EngineError::DispatchFailed(msg))
1604 }
1605 }
1606 })
1607 .await??;
1608
1609 // event broadcast (outside the lock — push_event feeds the in-memory tail; broadcast is a separate path).
1610 let _ = self.inner.event_tx.send(Event::TaskAttemptCompleted {
1611 task_id: task_id.clone(),
1612 attempt,
1613 result: match &outcome {
1614 DispatchOutcome::Pass(v)
1615 | DispatchOutcome::Blocked(v)
1616 | DispatchOutcome::Skip(v) => v.clone(),
1617 _ => Value::Null,
1618 },
1619 });
1620
1621 // Wake any callers waiting in poll_task.
1622 self.wake_task(&task_id).await?;
1623
1624 Ok(outcome)
1625 }
1626
1627 /// Dispatch a single attempt, opt-in to the replay-log Core primitive
1628 /// ([`crate::store::replay`]) via `run_ctx`.
1629 ///
1630 /// This is the [`Self::dispatch_attempt_with`] sibling used by callers
1631 /// that carry a `RunContext` with `replay_store` / `replay_cursor`
1632 /// populated. Behavior versus the plain `dispatch_attempt_with`:
1633 ///
1634 /// - **`run_ctx.replay_cursor` is `Some` AND the cursor has a matching
1635 /// `(step_ref, input_hash, occurrence)` row** — the stored value is
1636 /// returned verbatim as `DispatchOutcome::Pass(v)`; the `Adapter`
1637 /// (spawner + worker) is never touched. The task's `attempt` is
1638 /// still bumped and `TaskStatus` set to `Pass`, so downstream state
1639 /// (`task.last_result`, `TaskAttemptCompleted` / `TaskPass` events,
1640 /// `wake_task`) fires the same way an ordinary Pass would.
1641 /// - **Miss (or `replay_cursor: None`)** — the ordinary spawn path
1642 /// runs. When `run_ctx.replay_store` is `Some` AND the outcome is
1643 /// `Pass`, one `ReplayEntry` is appended carrying the whole `Ctx`
1644 /// snapshot (with `operator` dropped by `#[serde(skip)]`) plus the
1645 /// `step_output` value. `Blocked` / `Err` outcomes are never
1646 /// logged — a partial-failure row would poison the replay path
1647 /// after a subsequent successful retry.
1648 ///
1649 /// `run_ctx: None` collapses to the same behavior as
1650 /// `dispatch_attempt_with(token, task_id, spawner, None)` — no run
1651 /// tracing, no replay.
1652 pub async fn dispatch_attempt_with_run_ctx(
1653 &self,
1654 token: &CapToken,
1655 task_id: &StepId,
1656 spawner: &Arc<dyn SpawnerAdapter>,
1657 run_ctx: Option<&RunContext>,
1658 ) -> Result<DispatchOutcome, EngineError> {
1659 self.verify_token(token, Verb::DispatchAttempt).await?;
1660 let task_id = task_id.clone();
1661
1662 // 1) Under the lock: prep (bump attempt, snapshot agent/directive).
1663 let fp = token.fingerprint();
1664 let tid_for_prep = task_id.clone();
1665 let (attempt, agent, session_snapshot, step_ctx, initial_directive) = self
1666 .with_state("dispatch_run_ctx.prep", move |s| {
1667 let task = s
1668 .tasks
1669 .get_mut(&tid_for_prep)
1670 .ok_or_else(|| EngineError::TaskNotFound(tid_for_prep.to_string()))?;
1671 task.attempt += 1;
1672 task.status = TaskStatus::Running;
1673 task.updated_at = now_unix();
1674 let attempt = task.attempt;
1675 let initial = task.spec.initial_directive.clone();
1676 s.prompts
1677 .entry((tid_for_prep.clone(), attempt))
1678 .or_insert(initial.clone());
1679 let task = s
1680 .tasks
1681 .get(&tid_for_prep)
1682 .ok_or_else(|| EngineError::TaskNotFound(tid_for_prep.to_string()))?;
1683 let agent = task.spec.agent.clone();
1684 let step_ctx = task.spec.step_ctx.clone();
1685 let sess_clone = s
1686 .sessions
1687 .values()
1688 .find(|sess| sess.token_fp == fp)
1689 .cloned();
1690 Ok::<_, EngineError>((attempt, agent, sess_clone, step_ctx, initial))
1691 })
1692 .await??;
1693
1694 let operator_info = match session_snapshot {
1695 Some(sess) => self.resolve_operator_info(&sess, &agent).await,
1696 None => OperatorInfo::default(),
1697 };
1698
1699 // 2) Compute the replay key from step_ref (= agent) + hashed input.
1700 // Occurrence comes from the cursor's per-key counter (bumped
1701 // once per dispatch, so a loop that re-visits the same step
1702 // with the same input gets 0, 1, 2, … distinct rows).
1703 let step_ref = agent.clone();
1704 let input_hash = match run_ctx.and_then(|rc| rc.binding_digests.get(&step_ref)) {
1705 Some(binding_digest) => hash_input_value(&serde_json::json!({
1706 "input": initial_directive,
1707 "binding_digest": binding_digest,
1708 })),
1709 None => hash_input_value(&initial_directive),
1710 };
1711 let (replay_hit_value, occurrence) = if let Some(rc) = run_ctx {
1712 if let Some(cursor) = &rc.replay_cursor {
1713 let mut guard = cursor.lock().expect("replay cursor mutex poisoned");
1714 let occ = guard.next_occurrence(&step_ref, &input_hash);
1715 let hit = guard.find(&step_ref, &input_hash, occ);
1716 (hit, occ)
1717 } else {
1718 (None, 0)
1719 }
1720 } else {
1721 (None, 0)
1722 };
1723
1724 // 3) Build the Ctx that (a) either the spawner will see on a miss,
1725 // or (b) we log alongside the replay row.
1726 let mut ctx = Ctx::new(task_id.clone(), attempt, agent.clone());
1727 ctx.operator = operator_info;
1728 if let Some(rc) = run_ctx {
1729 ctx.meta
1730 .runtime
1731 .insert(RUN_ID_KEY.to_string(), Value::String(rc.run_id.to_string()));
1732 }
1733 if let Some(step_ctx) = step_ctx {
1734 ctx.meta.runtime.insert(STEP_CTX_KEY.to_string(), step_ctx);
1735 }
1736
1737 // 4) Replay-hit shortcut: skip the spawn+join, return stored value.
1738 let was_replay_hit = replay_hit_value.is_some();
1739 let value_ok: Result<(Value, bool), String> = if let Some(stored) = replay_hit_value {
1740 tracing::info!(
1741 task_id = %task_id,
1742 step_ref = %step_ref,
1743 occurrence = occurrence,
1744 "replayed from log; worker dispatch skipped"
1745 );
1746 Ok((stored, true))
1747 } else {
1748 // 5) Ordinary spawn path — mint a worker token+handle, run the
1749 // spawner, join, and pull the last Final from output_tail.
1750 let worker_token = self.inner.signer.session(
1751 format!("worker-of-{task_id}"),
1752 Role::Worker,
1753 vec!["*".into()],
1754 Duration::from_secs(1800),
1755 );
1756 let worker_fp = worker_token.fingerprint();
1757 let task_id_for_worker = task_id.clone();
1758 let worker_token_for_store = worker_token.clone();
1759 self.with_state("dispatch_run_ctx.mint_worker", move |s| {
1760 s.tokens.insert(
1761 worker_fp,
1762 CapTokenRecord::from_worker_token(worker_token_for_store, task_id_for_worker),
1763 );
1764 })
1765 .await?;
1766 let worker_handle = self.mint_worker_handle(worker_token.fingerprint()).await?;
1767 ctx.meta
1768 .runtime
1769 .insert("worker_handle".to_string(), Value::String(worker_handle));
1770
1771 let worker = spawner
1772 .spawn(self, &ctx, task_id.clone(), attempt, worker_token)
1773 .await
1774 .map_err(|e| EngineError::DispatchFailed(e.to_string()))?;
1775 let signal_result: Result<(), String> = worker.join().await.map_err(|e| e.to_string());
1776 match signal_result {
1777 Ok(()) => {
1778 let tail = self.output_tail(&task_id, attempt).await;
1779 let staged_names = self.worker_artifact_names_for(&task_id, attempt).await;
1780 fold_final_and_parts(&tail, &staged_names)
1781 .ok_or_else(|| "no Final in output_tail".to_string())
1782 }
1783 Err(msg) => Err(msg),
1784 }
1785 };
1786
1787 // 6) Apply — mirrors `dispatch_attempt_with`'s apply arm exactly
1788 // (task.last_result / status update + TaskAttemptCompleted /
1789 // TaskPass / TaskBlocked events).
1790 let outcome = self
1791 .with_state("dispatch_run_ctx.apply", |s| {
1792 if !s.tasks.contains_key(&task_id) {
1793 return Err(EngineError::TaskNotFound(task_id.to_string()));
1794 }
1795 match value_ok {
1796 Ok((value, ok)) => {
1797 // GH #76 Skip tier: Skip tier detection — same shape
1798 // as the sibling `dispatch_attempt_with` apply
1799 // arm above. See that arm's comment for the
1800 // TaskStatus / Event / DispatchOutcome contract.
1801 let skip_inner = if ok { unwrap_skip_marker(&value) } else { None };
1802 let pass = ok;
1803 {
1804 let task = s.tasks.get_mut(&task_id).unwrap();
1805 task.last_result = Some(value.clone());
1806 task.updated_at = now_unix();
1807 task.status = if pass {
1808 TaskStatus::Pass
1809 } else {
1810 TaskStatus::Blocked
1811 };
1812 }
1813 s.push_event(Event::TaskAttemptCompleted {
1814 task_id: task_id.clone(),
1815 attempt,
1816 result: value.clone(),
1817 });
1818 if let Some(inner) = skip_inner {
1819 s.push_event(Event::TaskPass {
1820 task_id: task_id.clone(),
1821 result: value.clone(),
1822 });
1823 Ok::<_, EngineError>(DispatchOutcome::Skip(inner))
1824 } else if pass {
1825 s.push_event(Event::TaskPass {
1826 task_id: task_id.clone(),
1827 result: value.clone(),
1828 });
1829 Ok::<_, EngineError>(DispatchOutcome::Pass(value))
1830 } else {
1831 s.push_event(Event::TaskBlocked {
1832 task_id: task_id.clone(),
1833 result: value.clone(),
1834 });
1835 Ok(DispatchOutcome::Blocked(value))
1836 }
1837 }
1838 Err(msg) => {
1839 let task = s.tasks.get_mut(&task_id).unwrap();
1840 task.status = TaskStatus::Blocked;
1841 task.updated_at = now_unix();
1842 Err(EngineError::DispatchFailed(msg))
1843 }
1844 }
1845 })
1846 .await??;
1847
1848 // 7) On MISS + Pass + replay_store present, append a replay row.
1849 // Replay-HIT rows are already logged from the original run and
1850 // must never be double-logged (Core primitive contract). A
1851 // secondary-persistence failure here (`tracing::warn!` +
1852 // swallow) matches the `run_ctx.run_store.append_step_entry`
1853 // convention in `EngineDispatcher::dispatch`: it must not mask
1854 // the primary dispatch outcome the caller already has in hand.
1855 if !was_replay_hit {
1856 if let (Some(rc), DispatchOutcome::Pass(v)) = (run_ctx, &outcome) {
1857 if let Some(store) = &rc.replay_store {
1858 match ReplayEntry::from_completion(
1859 rc.run_id.clone(),
1860 step_ref.clone(),
1861 input_hash.clone(),
1862 occurrence,
1863 &ctx,
1864 v,
1865 ) {
1866 Ok(entry) => {
1867 if let Err(e) = store.append(entry).await {
1868 tracing::warn!(
1869 run_id = %rc.run_id,
1870 step_ref = %step_ref,
1871 occurrence = occurrence,
1872 error = %e,
1873 "dispatch_attempt_with_run_ctx: replay_store.append failed"
1874 );
1875 }
1876 }
1877 Err(e) => {
1878 tracing::warn!(
1879 run_id = %rc.run_id,
1880 step_ref = %step_ref,
1881 occurrence = occurrence,
1882 error = %e,
1883 "dispatch_attempt_with_run_ctx: ReplayEntry encode failed"
1884 );
1885 }
1886 }
1887 }
1888 }
1889 }
1890
1891 let _ = self.inner.event_tx.send(Event::TaskAttemptCompleted {
1892 task_id: task_id.clone(),
1893 attempt,
1894 result: match &outcome {
1895 DispatchOutcome::Pass(v)
1896 | DispatchOutcome::Blocked(v)
1897 | DispatchOutcome::Skip(v) => v.clone(),
1898 _ => Value::Null,
1899 },
1900 });
1901
1902 self.wake_task(&task_id).await?;
1903
1904 Ok(outcome)
1905 }
1906
1907 // ═══════════════════════════════════════════════════════════════════════
1908 // Worker-side API (= prompt / data fetch + result post)
1909 // ═══════════════════════════════════════════════════════════════════════
1910
1911 /// Fetch the directive/prompt `Value` for `task_id`'s current attempt.
1912 /// Falls back to `initial_directive` when no prompt has been recorded
1913 /// yet for that attempt. Returns the `Value` end-to-end (issue #18);
1914 /// the render down to `String` happens only at the two consumer
1915 /// boundaries — the Worker HTTP path (`fetch_worker_payload*` →
1916 /// `WorkerPayload.prompt: String`) and the WS Spawn frame text
1917 /// render (`operator_ws::session`).
1918 pub async fn fetch_prompt(
1919 &self,
1920 token: &CapToken,
1921 task_id: &StepId,
1922 ) -> Result<Value, EngineError> {
1923 self.verify_token_for_task(token, Verb::FetchPrompt, task_id)
1924 .await?;
1925 let task_id = task_id.clone();
1926 self.with_state("fetch_prompt", move |s| {
1927 let task = s
1928 .tasks
1929 .get(&task_id)
1930 .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))?;
1931 s.prompts
1932 .get(&(task_id.clone(), task.attempt.max(1)))
1933 .cloned()
1934 .ok_or_else(|| {
1935 EngineError::ResourceNotFound(format!(
1936 "prompt({}, attempt={})",
1937 task_id, task.attempt
1938 ))
1939 })
1940 })
1941 .await?
1942 }
1943
1944 /// Combined fetch for `HTTP /v1/worker/prompt`: returns `prompt` +
1945 /// (optional) `system` + `agent` + `attempt` in a single round trip.
1946 /// The verb gate reuses `FetchPrompt` — same semantics as "the worker
1947 /// pulls its task input".
1948 ///
1949 /// `system` is the value written by `OperatorSpawner::spawn` through
1950 /// `bake_worker_system_prompt` when it ran; otherwise `None` (no
1951 /// profile present, or the bake never happened).
1952 pub async fn fetch_worker_payload(
1953 &self,
1954 token: &CapToken,
1955 task_id: &StepId,
1956 ) -> Result<crate::types::WorkerPayload, EngineError> {
1957 self.verify_token_for_task(token, Verb::FetchPrompt, task_id)
1958 .await?;
1959 let task_id_clone = task_id.clone();
1960 let mut payload = self
1961 .with_state("fetch_worker_payload", move |s| {
1962 let task = s
1963 .tasks
1964 .get(&task_id_clone)
1965 .ok_or_else(|| EngineError::TaskNotFound(task_id_clone.to_string()))?;
1966 let attempt = task.attempt.max(1);
1967 let prompt = s
1968 .prompts
1969 .get(&(task_id_clone.clone(), attempt))
1970 .cloned()
1971 .ok_or_else(|| {
1972 EngineError::ResourceNotFound(format!(
1973 "prompt({}, attempt={})",
1974 task_id_clone, attempt
1975 ))
1976 })?;
1977 let system = s
1978 .systems
1979 .get(&(task_id_clone.clone(), attempt))
1980 .cloned()
1981 .unwrap_or(None);
1982 let agent = task.spec.agent.clone();
1983 let context = s
1984 .agent_ctx
1985 .get(&(task_id_clone.clone(), attempt))
1986 .map(|e| e.view.clone());
1987 Ok::<_, EngineError>(crate::types::WorkerPayload {
1988 task_id: task_id_clone.clone(),
1989 attempt,
1990 agent,
1991 prompt: render_directive_to_string(&prompt),
1992 system,
1993 context,
1994 system_ref: None,
1995 })
1996 })
1997 .await??;
1998 self.apply_system_ref_threshold(&mut payload).await?;
1999 Ok(payload)
2000 }
2001
2002 /// Fetch a worker payload via a short handle. Skips token verification
2003 /// and returns `prompt` + `system` + `agent` + `attempt` in a thin
2004 /// path. The caller is expected to have already resolved `task_id`
2005 /// via `task_id_from_handle` — the handle's presence in
2006 /// `worker_handles` means it was minted server-side and is therefore
2007 /// trusted.
2008 pub async fn fetch_worker_payload_trusted(
2009 &self,
2010 task_id: &StepId,
2011 ) -> Result<crate::types::WorkerPayload, EngineError> {
2012 let task_id_clone = task_id.clone();
2013 let mut payload = self
2014 .with_state("fetch_worker_payload_trusted", move |s| {
2015 let task = s
2016 .tasks
2017 .get(&task_id_clone)
2018 .ok_or_else(|| EngineError::TaskNotFound(task_id_clone.to_string()))?;
2019 let attempt = task.attempt.max(1);
2020 let prompt = s
2021 .prompts
2022 .get(&(task_id_clone.clone(), attempt))
2023 .cloned()
2024 .ok_or_else(|| {
2025 EngineError::ResourceNotFound(format!(
2026 "prompt({}, attempt={})",
2027 task_id_clone, attempt
2028 ))
2029 })?;
2030 let system = s
2031 .systems
2032 .get(&(task_id_clone.clone(), attempt))
2033 .cloned()
2034 .unwrap_or(None);
2035 let agent = task.spec.agent.clone();
2036 let context = s
2037 .agent_ctx
2038 .get(&(task_id_clone.clone(), attempt))
2039 .map(|e| e.view.clone());
2040 Ok::<_, EngineError>(crate::types::WorkerPayload {
2041 task_id: task_id_clone.clone(),
2042 attempt,
2043 agent,
2044 prompt: render_directive_to_string(&prompt),
2045 system,
2046 context,
2047 system_ref: None,
2048 })
2049 })
2050 .await??;
2051 self.apply_system_ref_threshold(&mut payload).await?;
2052 Ok(payload)
2053 }
2054
2055 /// GH #31: shared threshold-branch tail for
2056 /// [`Self::fetch_worker_payload`] / [`Self::fetch_worker_payload_trusted`].
2057 /// Both build a raw `WorkerPayload` inside `with_state` with `system`
2058 /// populated as before and `system_ref: None`; this runs *outside* any
2059 /// lock (R3 — `SystemRefMode::File`'s `tokio::fs` write is a genuine
2060 /// `.await`, which `with_state`'s sync-closure contract forbids inside
2061 /// the lock) and rewrites `payload.system` / `payload.system_ref` in
2062 /// place per `SystemRefConfig.threshold_bytes`: over-threshold clears
2063 /// `system` and populates `system_ref`; at-or-under-threshold leaves
2064 /// `system` as-is and `system_ref` stays `None`. A no-op when
2065 /// `payload.system` is already `None` (no `system_prompt` was baked).
2066 async fn apply_system_ref_threshold(
2067 &self,
2068 payload: &mut crate::types::WorkerPayload,
2069 ) -> Result<(), EngineError> {
2070 let Some(rendered) = payload.system.take() else {
2071 return Ok(());
2072 };
2073 let cfg = self.cfg().system_ref.clone();
2074 if rendered.len() <= cfg.threshold_bytes {
2075 payload.system = Some(rendered);
2076 return Ok(());
2077 }
2078 use sha2::Digest;
2079 let size_bytes = rendered.len() as u64;
2080 let sha256 = hex::encode(sha2::Sha256::digest(rendered.as_bytes()));
2081 let task_id = &payload.task_id;
2082 let attempt = payload.attempt;
2083 let system_ref = match cfg.mode {
2084 crate::types::SystemRefMode::Http => crate::types::SystemRef {
2085 // The engine has no knowledge of scheme/host here — see
2086 // `SystemRefMode::Http`'s doc for who fills that in.
2087 uri: format!("/v1/worker/prompt/system?task_id={task_id}&attempt={attempt}"),
2088 sha256,
2089 size_bytes,
2090 mode: crate::types::SystemRefMode::Http,
2091 },
2092 crate::types::SystemRefMode::File => {
2093 tokio::fs::create_dir_all(&cfg.store_dir).await?;
2094 let path = cfg.store_dir.join(format!("{task_id}-{attempt}.md"));
2095 tokio::fs::write(&path, rendered.as_bytes()).await?;
2096 crate::types::SystemRef {
2097 uri: format!("file://{}", path.display()),
2098 sha256,
2099 size_bytes,
2100 mode: crate::types::SystemRefMode::File,
2101 }
2102 }
2103 };
2104 payload.system = None;
2105 payload.system_ref = Some(system_ref);
2106 Ok(())
2107 }
2108
2109 /// GH #83: unconditionally materialize the baked system prompt for
2110 /// `(task_id, attempt)` to a file and return its path — the value
2111 /// source of the `{system_file}` placeholder in a `SubprocessDef`
2112 /// template. Unlike [`Self::apply_system_ref_threshold`] (whose
2113 /// `SystemRefMode::File` write only fires over
2114 /// `SystemRefConfig.threshold_bytes`, a behavior this helper does NOT
2115 /// touch), a template that names `{system_file}` needs a real path
2116 /// regardless of size, so the write here is unconditional. Reuses the
2117 /// same store dir and `{task_id}-{attempt}.md` naming as the File
2118 /// mode, so both paths converge on one on-disk identity per attempt.
2119 ///
2120 /// `Ok(None)` = no system prompt was baked for this attempt (the
2121 /// caller decides whether that is fail-loud — the Subprocess spawn
2122 /// path treats a `{system_file}` reference without a baked system as
2123 /// a `SpawnError`).
2124 pub async fn materialize_system_file(
2125 &self,
2126 task_id: &StepId,
2127 attempt: u32,
2128 ) -> Result<Option<std::path::PathBuf>, EngineError> {
2129 let key = (task_id.clone(), attempt);
2130 let rendered = self
2131 .with_state("materialize_system_file", move |s| {
2132 s.systems.get(&key).cloned().unwrap_or(None)
2133 })
2134 .await?;
2135 let Some(rendered) = rendered else {
2136 return Ok(None);
2137 };
2138 let cfg = self.cfg().system_ref.clone();
2139 tokio::fs::create_dir_all(&cfg.store_dir).await?;
2140 let path = cfg.store_dir.join(format!("{task_id}-{attempt}.md"));
2141 tokio::fs::write(&path, rendered.as_bytes()).await?;
2142 Ok(Some(path))
2143 }
2144
2145 /// Returns the effective [`mlua_swarm_schema::ContextPolicy`]
2146 /// `AgentContextMiddleware` resolved and snapshotted for `(task_id,
2147 /// attempt)` at spawn time (the same policy already applied to that
2148 /// key's `EngineState.agent_ctx` entry's `.view`, GH #23 fold).
2149 /// Pass-all (`ContextPolicy::default()`) when no entry exists — either
2150 /// a pre-ST5 spawn, or a spawner stack that never layered
2151 /// `AgentContextMiddleware` (fail-open, mirroring [`Self::output_tail`]'s
2152 /// "no entry = empty default" convention).
2153 ///
2154 /// `crates/mlua-swarm-server/src/worker.rs`'s `GET /v1/worker/prompt`
2155 /// handler reads this back to filter `WorkerPayload.context.steps` via
2156 /// `ContextPolicy::allows_step`, without re-deriving the policy from
2157 /// the Blueprint at fetch time (`projection-adapter` ST5).
2158 pub async fn context_policy_for(
2159 &self,
2160 task_id: &StepId,
2161 attempt: u32,
2162 ) -> mlua_swarm_schema::ContextPolicy {
2163 let key = (task_id.clone(), attempt);
2164 self.with_state("context_policy_for", move |s| {
2165 s.agent_ctx
2166 .get(&key)
2167 .map(|e| e.policy.clone())
2168 .unwrap_or_default()
2169 })
2170 .await
2171 .unwrap_or_default()
2172 }
2173
2174 /// GH #23: returns the Blueprint-wide
2175 /// [`crate::core::step_naming::StepNaming`] table snapshotted for
2176 /// `task_id` (the same `Arc` `crate::blueprint::EngineDispatcher::dispatch`
2177 /// stashed into `EngineState.step_namings` at dispatch time —
2178 /// `Self::start_task`'s `StepId`, not the `TaskId` work item). `None`
2179 /// when no entry exists — either the dispatcher was never given a
2180 /// `StepNaming` (`EngineDispatcher::with_step_naming` not called) or
2181 /// the lock could not be acquired; callers are expected to fall back
2182 /// to the pre-GH-#23 runtime union rule in that case (subtask-2/3
2183 /// consumers).
2184 pub async fn step_naming_for(
2185 &self,
2186 task_id: &StepId,
2187 ) -> Option<Arc<crate::core::step_naming::StepNaming>> {
2188 let key = task_id.clone();
2189 self.with_state("step_naming_for", move |s| {
2190 s.step_namings.get(&key).cloned()
2191 })
2192 .await
2193 .ok()
2194 .flatten()
2195 }
2196
2197 /// GH #27 (follow-up to #23): returns the Blueprint-wide
2198 /// [`crate::core::projection_placement::ProjectionPlacement`] resolver
2199 /// snapshotted for `task_id` (the same `Arc`
2200 /// `crate::blueprint::EngineDispatcher::dispatch` stashed into
2201 /// `EngineState.projection_placements` at dispatch time — mirroring
2202 /// [`Self::step_naming_for`]'s contract exactly). `None` when no entry
2203 /// exists — either the dispatcher was never given a
2204 /// `ProjectionPlacement` (`EngineDispatcher::with_projection_placement`
2205 /// not called) or the lock could not be acquired; callers are expected
2206 /// to fall back to `ProjectionPlacement::default()` (byte-compat with
2207 /// the pre-#27 hardcoded layout) in that case.
2208 pub async fn projection_placement_for(
2209 &self,
2210 task_id: &StepId,
2211 ) -> Option<Arc<crate::core::projection_placement::ProjectionPlacement>> {
2212 let key = task_id.clone();
2213 self.with_state("projection_placement_for", move |s| {
2214 s.projection_placements.get(&key).cloned()
2215 })
2216 .await
2217 .ok()
2218 .flatten()
2219 }
2220
2221 /// Record normalized per-attempt worker stats reported by a worker
2222 /// boundary (spawner fold site / result captor / `POST
2223 /// /v1/worker/submit`). Last-write-wins per `(task_id, attempt)`.
2224 /// Best-effort: a state-lock failure is logged and swallowed —
2225 /// stats are observational and must never fail the attempt that
2226 /// produced them. Drained by [`Self::take_worker_stats`] at the
2227 /// dispatcher's outcome fold.
2228 pub async fn record_worker_stats(
2229 &self,
2230 task_id: &StepId,
2231 attempt: u32,
2232 stats: crate::store::trace::WorkerStats,
2233 ) {
2234 if stats.is_empty() {
2235 return;
2236 }
2237 let key = (task_id.clone(), attempt);
2238 if let Err(e) = self
2239 .with_state("record_worker_stats", move |s| {
2240 s.worker_stats.insert(key, stats);
2241 })
2242 .await
2243 {
2244 tracing::warn!(
2245 task_id = %task_id,
2246 attempt,
2247 error = %e,
2248 "record_worker_stats failed (swallowed — stats are observational)"
2249 );
2250 }
2251 }
2252
2253 /// Drain every recorded worker-stats entry for `task_id`, returning
2254 /// the highest-attempt one (the attempt whose outcome the dispatcher
2255 /// is folding). Removing ALL of the task's entries — not just the
2256 /// returned one — keeps retries from leaking earlier attempts into
2257 /// `EngineState` for the process lifetime.
2258 pub async fn take_worker_stats(
2259 &self,
2260 task_id: &StepId,
2261 ) -> Option<(u32, crate::store::trace::WorkerStats)> {
2262 let key_task = task_id.clone();
2263 self.with_state("take_worker_stats", move |s| {
2264 let attempts: Vec<u32> = s
2265 .worker_stats
2266 .keys()
2267 .filter(|(tid, _)| *tid == key_task)
2268 .map(|(_, a)| *a)
2269 .collect();
2270 let mut best: Option<(u32, crate::store::trace::WorkerStats)> = None;
2271 for attempt in attempts {
2272 if let Some(stats) = s.worker_stats.remove(&(key_task.clone(), attempt)) {
2273 if best.as_ref().map(|(a, _)| attempt >= *a).unwrap_or(true) {
2274 best = Some((attempt, stats));
2275 }
2276 }
2277 }
2278 best
2279 })
2280 .await
2281 .ok()
2282 .flatten()
2283 }
2284
2285 /// Returns the [`crate::store::trace::TraceHandle`] the dispatcher
2286 /// registered for `task_id`'s in-flight step, if any — the
2287 /// pervasive-insertion read port middlewares (and any other writer
2288 /// holding an `Engine`) use to append their own trace kinds. `None`
2289 /// = no trace rail for this dispatch (RunContext without a trace
2290 /// handle, or the step already folded).
2291 pub async fn trace_handle(&self, task_id: &StepId) -> Option<crate::store::trace::TraceHandle> {
2292 let key = task_id.clone();
2293 self.with_state("trace_handle", move |s| s.trace_handles.get(&key).cloned())
2294 .await
2295 .ok()
2296 .flatten()
2297 }
2298
2299 /// Register (or clear, with `None`) the per-dispatch trace handle
2300 /// for `task_id`. Called only by `EngineDispatcher::dispatch` —
2301 /// insert before spawn, clear after the outcome fold. Best-effort:
2302 /// registry failures are swallowed (trace is observational).
2303 pub(crate) async fn set_trace_handle(
2304 &self,
2305 task_id: &StepId,
2306 handle: Option<crate::store::trace::TraceHandle>,
2307 ) {
2308 let key = task_id.clone();
2309 let _ = self
2310 .with_state("set_trace_handle", move |s| match handle {
2311 Some(h) => {
2312 s.trace_handles.insert(key, h);
2313 }
2314 None => {
2315 s.trace_handles.remove(&key);
2316 }
2317 })
2318 .await;
2319 }
2320
2321 /// Returns the [`crate::core::agent_context::AgentContextView`]
2322 /// snapshotted for `(task_id, attempt)`, if `AgentContextMiddleware`
2323 /// stashed one — the same lookup [`Self::fetch_worker_payload`] /
2324 /// [`Self::fetch_worker_payload_trusted`] perform inline, exposed
2325 /// standalone for callers that only need the view (not a full
2326 /// `WorkerPayload`) — e.g. the HTTP debug-plane `GET
2327 /// /v1/tasks/:id/runs/:run/steps*` handlers resolving a
2328 /// materialized-file root for a step *other than* the one currently
2329 /// fetching its own prompt (`projection-adapter` ST5).
2330 pub async fn agent_context_for(
2331 &self,
2332 task_id: &StepId,
2333 attempt: u32,
2334 ) -> Option<crate::core::agent_context::AgentContextView> {
2335 let key = (task_id.clone(), attempt);
2336 self.with_state("agent_context_for", move |s| {
2337 s.agent_ctx.get(&key).map(|e| e.view.clone())
2338 })
2339 .await
2340 .ok()
2341 .flatten()
2342 }
2343
2344 /// Read the current attempt number for a task (server-side lookup, no
2345 /// token verification). Used on `HTTP /v1/worker/result` when the
2346 /// worker omits `attempt` and the server has to fill it in.
2347 pub async fn task_attempt(&self, task_id: &StepId) -> Result<u32, EngineError> {
2348 let task_id = task_id.clone();
2349 self.with_state("task_attempt", move |s| {
2350 s.tasks
2351 .get(&task_id)
2352 .map(|t| t.attempt)
2353 .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))
2354 })
2355 .await?
2356 }
2357
2358 /// Server-side admin API that lets `OperatorSpawner::spawn` bake the
2359 /// rendered `system_prompt` into engine state. There is no verb gate
2360 /// — the only expected caller is inside the spawner. SubAgents fetch
2361 /// this alongside the prompt on the `/v1/worker/prompt` path.
2362 pub async fn bake_worker_system_prompt(
2363 &self,
2364 task_id: &StepId,
2365 attempt: u32,
2366 system: Option<String>,
2367 ) -> Result<(), EngineError> {
2368 let task_id = task_id.clone();
2369 self.with_state("bake_worker_system_prompt", move |s| {
2370 // GH #31: record this agent's most-recently-baked render size
2371 // before `system` is moved into `s.systems.insert` below. Same
2372 // `s.tasks.get(&task_id)` → `.spec.agent` lookup pattern
2373 // `fetch_worker_payload` uses (see its doc for why this keying
2374 // is load-bearing for a later `bp_doctor` route).
2375 if let Some(rendered) = system.as_ref() {
2376 if let Some(agent) = s.tasks.get(&task_id).map(|t| t.spec.agent.clone()) {
2377 s.agent_render_sizes.insert(agent, rendered.len());
2378 }
2379 }
2380 s.systems.insert((task_id, attempt), system);
2381 })
2382 .await?;
2383 Ok(())
2384 }
2385
2386 /// GH #31: the most-recently-baked `system_prompt` render size (in
2387 /// bytes) observed for `agent_name`, if `bake_worker_system_prompt` has
2388 /// ever recorded one — last-write-wins across every `(task_id,
2389 /// attempt)` dispatch of that agent. `None` when no `system_prompt`
2390 /// has ever been baked for this agent name. Read by the `bp_doctor`
2391 /// route this subtask's follow-up adds.
2392 pub async fn agent_last_rendered_size(&self, agent_name: &str) -> Option<usize> {
2393 let agent_name = agent_name.to_string();
2394 self.with_state("agent_last_rendered_size", move |s| {
2395 s.agent_render_sizes.get(&agent_name).copied()
2396 })
2397 .await
2398 .ok()
2399 .flatten()
2400 }
2401
2402 /// GH #31: plain read-through of the baked `system` string for
2403 /// `(task_id, attempt)` from `EngineState.systems`, with no threshold
2404 /// branching. Backs `GET /v1/worker/prompt/system` (the `Http`-mode
2405 /// fetch target `system_ref.uri` points at) — that route needs the
2406 /// exact raw bytes to serve as the response body for the client's
2407 /// sha256 verification, not a `WorkerPayload`-wrapped value.
2408 ///
2409 /// Distinct from `apply_system_ref_threshold` (private, mutates an
2410 /// already-built `WorkerPayload` in place after full construction):
2411 /// this accessor has no threshold logic and is `pub` so
2412 /// `mlua-swarm-server`'s `worker` module can call it directly.
2413 ///
2414 /// Returns `Ok(None)` if no baked system exists for that `(task_id,
2415 /// attempt)` (either the task/attempt has no entry in `s.systems`, or
2416 /// the entry is present but stores `None`) — the caller maps this to
2417 /// a 404.
2418 pub async fn raw_system_prompt(
2419 &self,
2420 task_id: &StepId,
2421 attempt: u32,
2422 ) -> Result<Option<String>, EngineError> {
2423 let task_id = task_id.clone();
2424 self.with_state("raw_system_prompt", move |s| {
2425 s.systems.get(&(task_id, attempt)).cloned().unwrap_or(None)
2426 })
2427 .await
2428 }
2429
2430 /// Fetch an arbitrary named resource previously stored via
2431 /// `set_resource`. Not task-scoped — any valid token with the
2432 /// `FetchData` verb may read any key.
2433 pub async fn fetch_data(&self, token: &CapToken, key: &str) -> Result<Value, EngineError> {
2434 self.verify_token(token, Verb::FetchData).await?;
2435 let key = key.to_string();
2436 self.with_state("fetch_data", move |s| {
2437 s.resources
2438 .get(&key)
2439 .cloned()
2440 .ok_or(EngineError::ResourceNotFound(key))
2441 })
2442 .await?
2443 }
2444
2445 // ───────────────────────────────────────────────────────────────────────
2446 // Output path.
2447 // ───────────────────────────────────────────────────────────────────────
2448
2449 /// Send one output event from inside a `SpawnerAdapter` or worker.
2450 /// Structuring is assumed to be complete by the time we cross the
2451 /// `SpawnerAdapter` boundary; this API just appends to the
2452 /// `OutputStore`, pushes to the `EventLog`, and (for `Final`) emits
2453 /// the `TaskAttemptCompleted` event.
2454 ///
2455 /// This is Domain-side plumbing: it feeds the engine's verdict flow,
2456 /// not the Data-plane store in the `output_store` module. It also
2457 /// does not wake the dispatch path — that is done through the
2458 /// spawner's completion oneshot when the worker terminates.
2459 ///
2460 /// # Submit-time projection sink (subtask-4 / ST2 rework)
2461 ///
2462 /// A `Final` event additionally fans out to the submit-time projection
2463 /// sink ([`Self::materialize_final_submission`]): (a) when
2464 /// [`Self::set_output_store`] has wired a Data-plane
2465 /// [`crate::store::output::OutputStore`], the event is dual-written
2466 /// there (`producer_agent` = `TaskState.spec.agent`, resolved to its
2467 /// GH #23 canonical projection name — see below), and (b) when this
2468 /// task's spawn ran through `AgentContextMiddleware` (so
2469 /// `EngineState.agent_ctx` has a `.view.work_dir` / `.view.project_root`
2470 /// for it), the value is additionally materialized to the
2471 /// [`crate::core::projection_placement::ProjectionPlacement`]
2472 /// resolver's target (byte-compat default layout
2473 /// `<root>/workspace/tasks/<task_id>/ctx/<canonical_agent>.md`) — see
2474 /// `crate::core::projection`'s module doc.
2475 ///
2476 /// **GH #23 subtask-2 (canonical sink):** both writes above key off the
2477 /// canonical name — `Engine::step_naming_for(task_id)`'s
2478 /// `StepNaming::canonical_of_producer(producer_agent)` when a table was
2479 /// snapshotted for this task (`EngineDispatcher::with_step_naming`),
2480 /// else `producer_agent` unchanged (fail-open, byte-identical to
2481 /// pre-GH-#23 behavior — see [`crate::core::step_naming`]'s module
2482 /// doc).
2483 ///
2484 /// **Invariants** (Subtask 4): (1) this sink is fail-open — an
2485 /// unresolved root, an unconfigured `OutputStore`, or either one
2486 /// erroring, only logs a `tracing::warn!` and never turns this
2487 /// `Ok(())` into an `Err`; (2) the wired `OutputStore` stays the single
2488 /// source of truth for cross-step queries — the materialized file is a
2489 /// projection of it, not a second store; (3) core does not depend on
2490 /// `mlua-swarm-server` — everything this sink touches
2491 /// (`crate::store::output` / `crate::core::projection`) already lives
2492 /// in this crate.
2493 ///
2494 /// # `Artifact` dual-write (GH #34 subtask-3 gap fix)
2495 ///
2496 /// An `Artifact` event ALSO fans out to the Data-plane, via
2497 /// [`Self::materialize_artifact_submission`] — general-form: every
2498 /// `Artifact` submitted through this API dual-writes, no name-prefix
2499 /// gate. Unlike `Final`, the dual-write key is the artifact's own
2500 /// `name` field, verbatim — NOT resolved through the GH #23 canonical
2501 /// `StepNaming` table. An artifact's `name` IS its identity (mirrors
2502 /// [`crate::store::output::OutputStore::get_latest_by_name`]'s doc),
2503 /// so no canonicalization applies. Same fail-open discipline as
2504 /// `Final` (Invariant 1 above), but `Artifact` does NOT drive the
2505 /// file-materialize half (b) — artifact findings (e.g.
2506 /// `AfterRunAuditMiddleware`'s `"audit:<step_ref>"`) are observational
2507 /// sidecar data, not a step's own submission a work_dir/project_root
2508 /// projection needs to track. `Progress` / `Partial` events are
2509 /// unaffected — no behavior change.
2510 pub async fn submit_output(
2511 &self,
2512 token: &crate::types::CapToken,
2513 task_id: &StepId,
2514 attempt: u32,
2515 event: crate::worker::output::OutputEvent,
2516 ) -> Result<(), EngineError> {
2517 self.verify_token_for_task(token, crate::types::Verb::EmitOutput, task_id)
2518 .await?;
2519 // GH #51 — completion-time verdict-contract enforcement, embedded
2520 // choke point 2 of 2 (see `Self::verdict_contract_completion_check`'s
2521 // doc). Guarded to `Final` only — the ONLY `OutputEvent` variant a
2522 // verdict contract's completion can meaningfully address; this
2523 // guard is defensive (this function is empirically called with
2524 // `Final` only today, both from `worker.rs`'s `worker_result` and
2525 // from `operator.rs`'s WS fallback) but costs nothing and protects
2526 // against a future non-`Final` caller. Runs BEFORE the
2527 // `output_tail` write immediately below: on `Err`, this returns
2528 // immediately and the write never happens — a rejected value
2529 // never reaches `output_tail` / the flow ctx.
2530 if let crate::worker::output::OutputEvent::Final { content, ok } = &event {
2531 let comparable_value = content_ref_to_comparable_string(content.clone());
2532 self.verdict_contract_completion_check(task_id, attempt, *ok, &comparable_value)
2533 .await?;
2534 }
2535 let task_id_for_apply = task_id.clone();
2536 let event_clone = event.clone();
2537 self.with_state("submit_output", move |s| {
2538 s.output_store
2539 .entry((task_id_for_apply.clone(), attempt))
2540 .or_default()
2541 .push(event_clone.clone());
2542 s.push_event(crate::core::state::Event::WorkerOutput {
2543 task_id: task_id_for_apply,
2544 attempt,
2545 event: event_clone,
2546 });
2547 })
2548 .await?;
2549 match &event {
2550 crate::worker::output::OutputEvent::Final { content, ok } => {
2551 self.materialize_final_submission(task_id, attempt, content, *ok)
2552 .await?;
2553 }
2554 crate::worker::output::OutputEvent::Artifact { name, content } => {
2555 self.materialize_artifact_submission(task_id, attempt, name, content)
2556 .await?;
2557 }
2558 _ => {}
2559 }
2560 Ok(())
2561 }
2562
2563 /// Submit-time projection sink (subtask-4 / ST2 rework) shared by
2564 /// [`Self::submit_output`] and [`Self::submit_worker_result_trusted`].
2565 /// Best-effort / fail-open throughout (see `submit_output`'s doc
2566 /// Invariants): every failure path only `tracing::warn!`s and returns.
2567 ///
2568 /// Reads `(producer_agent, view)` via one read-only [`Self::with_state`]
2569 /// call — `producer_agent` off `TaskState.spec.agent`, `view` (the
2570 /// full [`crate::core::agent_context::AgentContextView`]) off
2571 /// `EngineState.agent_ctx[(task_id, attempt)]`, the same snapshot
2572 /// `crate::middleware::agent_context::AgentContextMiddleware` writes at
2573 /// spawn time — then does its actual (dual-write / file-write) work
2574 /// *outside* that lock, so a slow disk write or Data-plane store call
2575 /// never holds up unrelated `Engine::with_state` callers. `root` itself
2576 /// is resolved from `view` AFTER the lock via
2577 /// [`crate::core::projection_placement::ProjectionPlacement::resolve_root`]
2578 /// (GH #27, follow-up to #23) — the SAME resolver
2579 /// [`Self::step_naming_for`]'s sibling accessor
2580 /// [`Self::projection_placement_for`] snapshotted at dispatch time, so
2581 /// this sink's root-preference / fallback order is identical to the
2582 /// server read-back and the spawn-time pointer.
2583 async fn materialize_final_submission(
2584 &self,
2585 task_id: &StepId,
2586 attempt: u32,
2587 content: &crate::worker::output::ContentRef,
2588 ok: bool,
2589 ) -> Result<(), EngineError> {
2590 let server_policy = self.cfg().check_policy;
2591 let task_id_for_lookup = task_id.clone();
2592 let lookup = self
2593 .with_state("materialize_final_submission.lookup", move |s| {
2594 let entry = s.tasks.get(&task_id_for_lookup);
2595 let producer_agent = entry.map(|t| t.spec.agent.clone());
2596 let task_policy = entry.and_then(|t| t.spec.check_policy);
2597 let view = s
2598 .agent_ctx
2599 .get(&(task_id_for_lookup.clone(), attempt))
2600 .map(|e| e.view.clone());
2601 (producer_agent, task_policy, view)
2602 })
2603 .await;
2604 // Per-task `TaskSpec.check_policy` (ST1c) wins
2605 // over the server-wide `EngineCfg.check_policy` when set — a
2606 // per-run override forwarded from the launch entry point (see
2607 // `TaskLaunchRequest.check_policy` /
2608 // `TaskLaunchInput.check_policy`). `None` leaves the server
2609 // default in effect (backward compat).
2610 let policy = lookup
2611 .as_ref()
2612 .ok()
2613 .and_then(|(_, tp, _)| *tp)
2614 .unwrap_or(server_policy);
2615 let (producer_agent, view) = match lookup.map(|(pa, _, view)| (pa, view)) {
2616 Ok(pair) => pair,
2617 Err(err) => {
2618 if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2619 tracing::warn!(
2620 %task_id,
2621 error = %err,
2622 "submit-time projection sink: state lookup failed; skipping (fail-open)"
2623 );
2624 }
2625 apply_check_policy(
2626 policy,
2627 "submit-time projection sink: state lookup",
2628 "state lookup failed; skipping (fail-open)",
2629 )?;
2630 return Ok(());
2631 }
2632 };
2633 let Some(producer_agent) = producer_agent else {
2634 // Defensive only: `task_id` is always a just-looked-up task at
2635 // every real call site. No task, no addressable producer name
2636 // — nothing to project. Not gated by `CheckPolicy` — a missing
2637 // task is an intentional early-exit path, not a fail-open
2638 // condition to surface.
2639 return Ok(());
2640 };
2641 let placement = self
2642 .projection_placement_for(task_id)
2643 .await
2644 .unwrap_or_default();
2645 let root = view.and_then(|v| placement.resolve_root(&v));
2646
2647 // GH #23 subtask-2: resolve `producer_agent` to its canonical
2648 // projection name via the Blueprint-wide `StepNaming` table
2649 // snapshotted at dispatch time (`Engine::step_naming_for`). Both
2650 // write paths below ((a) data-plane, (b) file stem) use the
2651 // *canonical* name — `StepNaming::canonical_of_producer` returns
2652 // `producer_agent` unchanged for undeclared steps (byte-identical
2653 // to pre-GH-#23 behavior), and `None` (no table for this
2654 // `task_id`, e.g. a spawn that never went through
2655 // `EngineDispatcher::with_step_naming`) is a defensive fail-open
2656 // to the raw `producer_agent`, same discipline as the rest of this
2657 // sink.
2658 let canonical_agent = self
2659 .step_naming_for(task_id)
2660 .await
2661 .and_then(|naming| {
2662 naming
2663 .canonical_of_producer(&producer_agent)
2664 .map(str::to_string)
2665 })
2666 .unwrap_or_else(|| producer_agent.clone());
2667
2668 // (a) Data-plane dual-write, when an OutputStore backend is wired.
2669 if let Some(store) = self.output_store_backend() {
2670 if let Err(err) = store
2671 .append(
2672 task_id.as_str(),
2673 attempt,
2674 &canonical_agent,
2675 crate::worker::output::OutputEvent::Final {
2676 content: content.clone(),
2677 ok,
2678 },
2679 Vec::new(),
2680 )
2681 .await
2682 {
2683 if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2684 tracing::warn!(
2685 %task_id,
2686 agent = %producer_agent,
2687 canonical = %canonical_agent,
2688 error = %err,
2689 "submit-time projection sink: OutputStore dual-write failed (fail-open)"
2690 );
2691 }
2692 apply_check_policy(
2693 policy,
2694 "submit-time projection sink: OutputStore dual-write",
2695 "OutputStore dual-write failed (fail-open)",
2696 )?;
2697 }
2698 }
2699
2700 // (b) File materialize, when a root resolved.
2701 let Some(root) = root else {
2702 if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2703 tracing::warn!(
2704 %task_id,
2705 agent = %producer_agent,
2706 canonical = %canonical_agent,
2707 "submit-time projection sink: no work_dir/project_root resolved; skipping file materialize (fail-open)"
2708 );
2709 }
2710 apply_check_policy(
2711 policy,
2712 "submit-time projection sink: file materialize",
2713 "no work_dir/project_root resolved; skipping file materialize (fail-open)",
2714 )?;
2715 return Ok(());
2716 };
2717 let value = match content {
2718 crate::worker::output::ContentRef::Inline { value } => value.clone(),
2719 crate::worker::output::ContentRef::FileRef {
2720 path,
2721 mime,
2722 size_hint,
2723 } => serde_json::json!({
2724 "file_ref": path.to_string_lossy(),
2725 "mime": mime,
2726 "size_hint": size_hint,
2727 }),
2728 };
2729 let key = crate::core::projection::ProjectionKey {
2730 task_id: task_id.to_string(),
2731 run_id: None,
2732 step: Some(canonical_agent.clone()),
2733 path: None,
2734 };
2735 let adapter = crate::core::projection::FileProjectionAdapter::with_placement(
2736 root,
2737 (*placement).clone(),
2738 );
2739 if let Err(err) = adapter.materialize_submission(&key, &value, attempt, ok) {
2740 if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2741 tracing::warn!(
2742 %task_id,
2743 agent = %producer_agent,
2744 canonical = %canonical_agent,
2745 error = %err,
2746 "submit-time projection sink: file materialize failed (fail-open)"
2747 );
2748 }
2749 apply_check_policy(
2750 policy,
2751 "submit-time projection sink: file materialize",
2752 "file materialize failed (fail-open)",
2753 )?;
2754 }
2755 Ok(())
2756 }
2757
2758 /// Submit-time projection sink for `OutputEvent::Artifact` (GH #34
2759 /// subtask-3, later extended to drive the file half too). Two halves, the
2760 /// [`Self::materialize_final_submission`] mirror for staged named parts:
2761 ///
2762 /// - **Data-plane dual-write** — when [`Self::set_output_store`] has
2763 /// wired a [`crate::store::output::OutputStore`], the artifact
2764 /// dual-writes there under its own `name`, verbatim (general form:
2765 /// every `Artifact` staged via [`Self::submit_output`] /
2766 /// [`Self::stage_worker_artifact_trusted`] materializes this way, no
2767 /// name-prefix gate).
2768 /// - **File materialize** — when a `root` resolves off the spawn-time
2769 /// [`crate::core::agent_context::AgentContextView`], the part's
2770 /// content is written raw to `<ctx-dir>/<name>` via
2771 /// [`crate::core::projection::FileProjectionAdapter::materialize_part`].
2772 /// That file is the IN file the *next* Agent step reads: materializing
2773 /// a Step's OUTPUT to disk is the
2774 /// [`crate::core::projection::FileProjectionAdapter`]'s
2775 /// responsibility, and a staged named part is as much an OUTPUT the
2776 /// next step consumes as a `Final` is — so the sink materializes it
2777 /// too, rather than leaving parts Data-plane-only.
2778 ///
2779 /// Unlike the Final sink, no `StepNaming` canonicalization is applied:
2780 /// an artifact's `name` already IS the key both halves address (it
2781 /// names the file directly, extension included — `plan.md` — so
2782 /// `materialize_part` writes it verbatim, not through the `<stem>.md`
2783 /// synthesis the Final sink's canonical-agent path uses).
2784 ///
2785 /// Fail-open throughout, the same `check_policy` cascade as
2786 /// [`Self::materialize_final_submission`]: a per-task lookup error falls
2787 /// back to the server default (and a `None` view ⇒ the file half's
2788 /// unresolved-root path), an unconfigured `OutputStore` skips the
2789 /// dual-write, an unresolved root skips the file half, and a
2790 /// dual-write / file-write / name-guard error only `tracing::warn!`s
2791 /// (`Silent` suppresses even that) before applying [`apply_check_policy`]
2792 /// (`Strict` surfaces an [`EngineError`], `Warn` / `Silent` return
2793 /// `Ok(())`) — a staged part never turns a would-have-succeeded submit
2794 /// into a failure under the default policy.
2795 async fn materialize_artifact_submission(
2796 &self,
2797 task_id: &StepId,
2798 attempt: u32,
2799 name: &str,
2800 content: &crate::worker::output::ContentRef,
2801 ) -> Result<(), EngineError> {
2802 // Per-task `TaskSpec.check_policy` override + the `AgentContextView`
2803 // snapshot, resolved in ONE read-only `with_state` (the same lock
2804 // the policy lookup already needed — no extra `with_state` for the
2805 // view). Silent per-task lookup failure (`with_state` error) falls
2806 // back to the server-wide default and a `None` view (⇒ the file
2807 // half's own unresolved-root fail-open path); this sink never
2808 // surfaces the lookup error itself as a step failure.
2809 let server_policy = self.cfg().check_policy;
2810 let task_id_for_lookup = task_id.clone();
2811 let lookup = self
2812 .with_state("materialize_artifact_submission.lookup", move |s| {
2813 let task_policy = s
2814 .tasks
2815 .get(&task_id_for_lookup)
2816 .and_then(|t| t.spec.check_policy);
2817 let view = s
2818 .agent_ctx
2819 .get(&(task_id_for_lookup.clone(), attempt))
2820 .map(|e| e.view.clone());
2821 (task_policy, view)
2822 })
2823 .await
2824 .ok();
2825 let policy = lookup
2826 .as_ref()
2827 .and_then(|(tp, _)| *tp)
2828 .unwrap_or(server_policy);
2829 let view = lookup.and_then(|(_, view)| view);
2830
2831 // (a) Data-plane dual-write, when an OutputStore backend is wired —
2832 // the artifact's own `name` is its Data-plane key (no
2833 // canonicalization, unlike the Final sink's `StepNaming`
2834 // resolution).
2835 if let Some(store) = self.output_store_backend() {
2836 if let Err(err) = store
2837 .append(
2838 task_id.as_str(),
2839 attempt,
2840 name,
2841 crate::worker::output::OutputEvent::Artifact {
2842 name: name.to_string(),
2843 content: content.clone(),
2844 },
2845 Vec::new(),
2846 )
2847 .await
2848 {
2849 if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2850 tracing::warn!(
2851 %task_id,
2852 artifact = %name,
2853 error = %err,
2854 "submit-time projection sink: OutputStore dual-write failed for Artifact (fail-open)"
2855 );
2856 }
2857 apply_check_policy(
2858 policy,
2859 "submit-time projection sink: Artifact OutputStore dual-write",
2860 "OutputStore dual-write failed for Artifact (fail-open)",
2861 )?;
2862 }
2863 }
2864
2865 // (b) File materialize, when a root resolved — writes the staged
2866 // part raw to `<ctx-dir>/<name>`, the IN file the next Agent step
2867 // reads (see `FileProjectionAdapter::materialize_part`'s doc for
2868 // why raw / why the name is verbatim). A name-guard violation lands
2869 // on the same fail-open path as any other write error below.
2870 let placement = self
2871 .projection_placement_for(task_id)
2872 .await
2873 .unwrap_or_default();
2874 let Some(root) = view.and_then(|v| placement.resolve_root(&v)) else {
2875 if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2876 tracing::warn!(
2877 %task_id,
2878 artifact = %name,
2879 "submit-time projection sink: no work_dir/project_root resolved; skipping part file materialize (fail-open)"
2880 );
2881 }
2882 apply_check_policy(
2883 policy,
2884 "submit-time projection sink: part file materialize",
2885 "no work_dir/project_root resolved; skipping part file materialize (fail-open)",
2886 )?;
2887 return Ok(());
2888 };
2889 let value = match content {
2890 crate::worker::output::ContentRef::Inline { value } => value.clone(),
2891 crate::worker::output::ContentRef::FileRef {
2892 path,
2893 mime,
2894 size_hint,
2895 } => serde_json::json!({
2896 "file_ref": path.to_string_lossy(),
2897 "mime": mime,
2898 "size_hint": size_hint,
2899 }),
2900 };
2901 let adapter = crate::core::projection::FileProjectionAdapter::with_placement(
2902 root,
2903 (*placement).clone(),
2904 );
2905 if let Err(err) = adapter.materialize_part(task_id.as_str(), name, &value) {
2906 if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2907 tracing::warn!(
2908 %task_id,
2909 artifact = %name,
2910 error = %err,
2911 "submit-time projection sink: part file materialize failed (fail-open)"
2912 );
2913 }
2914 apply_check_policy(
2915 policy,
2916 "submit-time projection sink: part file materialize",
2917 "part file materialize failed (fail-open)",
2918 )?;
2919 }
2920 Ok(())
2921 }
2922
2923 /// Snapshot the entire output tail for a given `(task_id, attempt)`.
2924 /// Used by the dispatch path when pulling `Final`, and by observers
2925 /// reading the trace.
2926 pub async fn output_tail(
2927 &self,
2928 task_id: &StepId,
2929 attempt: u32,
2930 ) -> Vec<crate::worker::output::OutputEvent> {
2931 let key = (task_id.clone(), attempt);
2932 self.with_state("output_tail", move |s| {
2933 s.output_store.get(&key).cloned().unwrap_or_default()
2934 })
2935 .await
2936 .unwrap_or_default()
2937 }
2938
2939 /// Record an interim `last_result` for `task_id` without changing its
2940 /// `status`. Distinct from the terminal `Final` output event handled
2941 /// through `submit_output` / `dispatch_attempt_with`.
2942 pub async fn post_result(
2943 &self,
2944 token: &CapToken,
2945 task_id: &StepId,
2946 result: Value,
2947 ) -> Result<(), EngineError> {
2948 self.verify_token_for_task(token, Verb::PostResult, task_id)
2949 .await?;
2950 let task_id = task_id.clone();
2951 let result_clone = result.clone();
2952 self.with_state("post_result", move |s| {
2953 let task = s
2954 .tasks
2955 .get_mut(&task_id)
2956 .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))?;
2957 task.last_result = Some(result_clone);
2958 task.updated_at = now_unix();
2959 Ok::<(), EngineError>(())
2960 })
2961 .await??;
2962 Ok(())
2963 }
2964
2965 /// Store a named resource value, retrievable later via `fetch_data`.
2966 /// No token is required — this is a server-side/admin-style setter
2967 /// (mirrors `bake_worker_system_prompt`).
2968 pub async fn set_resource(
2969 &self,
2970 key: impl Into<String>,
2971 value: Value,
2972 ) -> Result<(), EngineError> {
2973 let key = key.into();
2974 self.with_state("set_resource", move |s| {
2975 s.resources.insert(key, value);
2976 })
2977 .await?;
2978 Ok(())
2979 }
2980
2981 // ═══════════════════════════════════════════════════════════════════════
2982 // Senior suspend / resume
2983 // ═══════════════════════════════════════════════════════════════════════
2984
2985 /// Ask a question of the Senior, mark the task `Suspended`, and
2986 /// return a `ResumeKey`. The suspended state persists until another
2987 /// task calls `resume(key, answer)`.
2988 ///
2989 /// Resume-side waiting is `Notify`-based, so a caller (typically
2990 /// MainAI) can detach, reattach from a different process, and still
2991 /// pull the answer out via `await_resume(key, timeout)` — the answer
2992 /// is stored inside `EngineState`.
2993 pub async fn query_senior(
2994 &self,
2995 token: &CapToken,
2996 task_id: &StepId,
2997 question: Value,
2998 ) -> Result<ResumeKey, EngineError> {
2999 self.verify_token(token, Verb::QuerySenior).await?;
3000 let task_id = task_id.clone();
3001 let key = ResumeKey::for_senior(&task_id);
3002 let task_notify = self
3003 .with_state("query_senior.notify_ensure", |s| {
3004 s.ensure_task_notify(&task_id)
3005 })
3006 .await?;
3007
3008 let key_clone = key.clone();
3009 let task_id_inner = task_id.clone();
3010 let question_clone = question.clone();
3011 self.with_state("query_senior.suspend", move |s| {
3012 let task = s
3013 .tasks
3014 .get_mut(&task_id_inner)
3015 .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))?;
3016 task.status = TaskStatus::Suspended;
3017 task.suspended_on = Some(key_clone.clone());
3018 task.updated_at = now_unix();
3019 s.pending_resumes
3020 .insert(key_clone.clone(), ResumePending::new());
3021 s.push_event(Event::SeniorQueried {
3022 task_id: task_id_inner.clone(),
3023 question: question_clone.clone(),
3024 });
3025 s.push_event(Event::TaskSuspended {
3026 task_id: task_id_inner.clone(),
3027 key: key_clone.clone(),
3028 });
3029 Ok::<(), EngineError>(())
3030 })
3031 .await??;
3032
3033 // Notify callers waiting for a task status change (Running → Suspended).
3034 task_notify.notify_waiters();
3035
3036 let _ = self
3037 .inner
3038 .event_tx
3039 .send(Event::SeniorQueried { task_id, question });
3040 Ok(key)
3041 }
3042
3043 /// Store the answer for a `ResumeKey` in `EngineState` and wake the
3044 /// waiting caller via `Notify`. Also flips the suspended task's
3045 /// status back to `Running` and fires the per-task notifier.
3046 pub async fn resume(&self, key: ResumeKey, answer: Value) -> Result<(), EngineError> {
3047 let answer_for_state = answer.clone();
3048 let answer_for_event = answer.clone();
3049 let key_clone = key.clone();
3050 let (notify, task_notify, task_id_opt) = self
3051 .with_state("resume.set", move |s| {
3052 let pending = s
3053 .pending_resumes
3054 .get_mut(&key_clone)
3055 .ok_or(EngineError::ResumeKeyNotFound)?;
3056 pending.answer = Some(answer_for_state);
3057 let notify = pending.notify.clone();
3058
3059 let task_id = s
3060 .tasks
3061 .iter()
3062 .find(|(_, t)| t.suspended_on.as_ref() == Some(&key_clone))
3063 .map(|(id, _)| id.clone());
3064
3065 let task_notify = task_id.as_ref().map(|tid| s.ensure_task_notify(tid));
3066
3067 if let Some(tid) = &task_id {
3068 if let Some(task) = s.tasks.get_mut(tid) {
3069 task.suspended_on = None;
3070 task.status = TaskStatus::Running;
3071 task.updated_at = now_unix();
3072 }
3073 s.push_event(Event::TaskResumed {
3074 task_id: tid.clone(),
3075 key: key_clone.clone(),
3076 });
3077 s.push_event(Event::SeniorAnswered {
3078 task_id: tid.clone(),
3079 answer: answer_for_event.clone(),
3080 });
3081 }
3082 Ok::<_, EngineError>((notify, task_notify, task_id))
3083 })
3084 .await??;
3085
3086 // Outside the lock: notify_waiters for both the ResumePending and task-status waits.
3087 notify.notify_waiters();
3088 if let Some(n) = task_notify {
3089 n.notify_waiters();
3090 }
3091
3092 if let Some(tid) = task_id_opt {
3093 let _ = self
3094 .inner
3095 .event_tx
3096 .send(Event::TaskResumed { task_id: tid, key });
3097 }
3098 Ok(())
3099 }
3100
3101 /// Wait for the resume answer. Even if the caller (an Operator)
3102 /// detached and reattached, the answer is available immediately here
3103 /// — if it was already stored, this returns without waiting on the
3104 /// notifier.
3105 ///
3106 /// `timeout = Duration::ZERO` performs an instant check without
3107 /// waiting.
3108 pub async fn await_resume(
3109 &self,
3110 key: ResumeKey,
3111 timeout: Duration,
3112 ) -> Result<Value, EngineError> {
3113 // (1) Under the lock: clone the notify handle and check for an existing answer.
3114 let key_clone = key.clone();
3115 let (notify, existing) = self
3116 .with_state("await_resume.snapshot", move |s| {
3117 let pending = s
3118 .pending_resumes
3119 .get(&key_clone)
3120 .ok_or(EngineError::ResumeKeyNotFound)?;
3121 Ok::<_, EngineError>((pending.notify.clone(), pending.answer.clone()))
3122 })
3123 .await??;
3124
3125 // (2) If an answer has already been stored, return immediately (detach / reattach pattern).
3126 if let Some(v) = existing {
3127 return Ok(v);
3128 }
3129
3130 // (3) Outside the lock: wait on the notify with a timeout.
3131 if timeout.is_zero() {
3132 return Err(EngineError::PollTimeout);
3133 }
3134 let waited = tokio::time::timeout(timeout, notify.notified()).await;
3135 if waited.is_err() {
3136 return Err(EngineError::PollTimeout);
3137 }
3138
3139 // (4) Under the lock: re-read the answer (should be present now that we were notified).
3140 let key_clone = key.clone();
3141 self.with_state("await_resume.read", move |s| {
3142 let pending = s
3143 .pending_resumes
3144 .get(&key_clone)
3145 .ok_or(EngineError::ResumeKeyNotFound)?;
3146 pending
3147 .answer
3148 .clone()
3149 .ok_or_else(|| EngineError::Internal("notified but answer missing".into()))
3150 })
3151 .await?
3152 }
3153
3154 // ═══════════════════════════════════════════════════════════════════════
3155 // poll_task — the "wait" path that waits for task-status changes (works for long-poll and regular wait).
3156 // ═══════════════════════════════════════════════════════════════════════
3157
3158 /// Wait until the task's status **transitions to terminal or
3159 /// `Suspended`**, then return the latest `TaskState`. Returns
3160 /// immediately if the task is already in a terminal state.
3161 /// Exceeding the timeout returns `EngineError::PollTimeout`.
3162 ///
3163 /// A `hold` of `Duration::from_secs(0)` returns a snapshot immediately
3164 /// (no wait). Larger holds — tens of minutes up to days — are fine;
3165 /// the wait state is kept in memory inside the engine and does not
3166 /// degrade.
3167 pub async fn poll_task(
3168 &self,
3169 token: &CapToken,
3170 task_id: &StepId,
3171 hold: Duration,
3172 ) -> Result<TaskState, EngineError> {
3173 self.verify_token_for_task(token, Verb::PollTask, task_id)
3174 .await?;
3175 let task_id_inner = task_id.clone();
3176
3177 // (1) Under the lock: take a snapshot and clone task_notify.
3178 let (state, notify) = self
3179 .with_state("poll_task.snapshot", move |s| {
3180 let task = s
3181 .tasks
3182 .get(&task_id_inner)
3183 .cloned()
3184 .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))?;
3185 let notify = s.ensure_task_notify(&task_id_inner);
3186 Ok::<_, EngineError>((task, notify))
3187 })
3188 .await??;
3189
3190 // (2) Immediate-return condition: already terminal / Suspended (nothing left to wait on).
3191 if matches!(
3192 state.status,
3193 TaskStatus::Pass | TaskStatus::Blocked | TaskStatus::Cancelled | TaskStatus::Suspended
3194 ) {
3195 return Ok(state);
3196 }
3197 if hold.is_zero() {
3198 return Ok(state);
3199 }
3200
3201 // (3) Outside the lock: wait on Notify with a timeout.
3202 let waited = tokio::time::timeout(hold, notify.notified()).await;
3203 if waited.is_err() {
3204 return Err(EngineError::PollTimeout);
3205 }
3206
3207 // (4) Under the lock: take a fresh snapshot.
3208 let task_id_inner = task_id.clone();
3209 self.with_state("poll_task.reread", move |s| {
3210 s.tasks
3211 .get(&task_id_inner)
3212 .cloned()
3213 .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))
3214 })
3215 .await?
3216 }
3217
3218 // ═══════════════════════════════════════════════════════════════════════
3219 // Background: heartbeat miss → detach loop
3220 // ═══════════════════════════════════════════════════════════════════════
3221
3222 /// Background loop that scans sessions every `heartbeat_interval` and
3223 /// flips `attached = false` on any session whose `last_seen` exceeds
3224 /// `heartbeat_miss_threshold * interval`.
3225 ///
3226 /// The tasks themselves are kept (assuming
3227 /// `keepalive_on_idle = true`), so another client can reattach with
3228 /// the same token and resume immediately. Dropping the returned
3229 /// `JoinHandle` does not stop the loop — the handle exists so callers
3230 /// who want to abort can hold onto it.
3231 pub fn start_detach_loop(&self) -> tokio::task::JoinHandle<()> {
3232 let engine = self.clone();
3233 let cfg = self.inner.cfg.long_hold.clone();
3234 let interval = cfg.heartbeat_interval;
3235 let miss_secs = cfg.heartbeat_interval.as_secs() * cfg.heartbeat_miss_threshold as u64;
3236
3237 tokio::spawn(async move {
3238 let mut ticker = tokio::time::interval(interval);
3239 ticker.tick().await; // first tick is immediate
3240 loop {
3241 ticker.tick().await;
3242 let now = now_unix();
3243 let detached = engine
3244 .with_state("detach_loop.scan", |s| {
3245 let mut detached = Vec::new();
3246 for (sid, sess) in s.sessions.iter_mut() {
3247 if !sess.attached {
3248 continue;
3249 }
3250 if now.saturating_sub(sess.last_seen) >= miss_secs {
3251 sess.attached = false;
3252 detached.push(sid.clone());
3253 }
3254 }
3255 for sid in &detached {
3256 s.push_event(Event::SessionDetached {
3257 session_id: sid.clone(),
3258 });
3259 }
3260 detached
3261 })
3262 .await
3263 .unwrap_or_default();
3264 for sid in detached {
3265 let _ = engine
3266 .inner
3267 .event_tx
3268 .send(Event::SessionDetached { session_id: sid });
3269 }
3270 }
3271 })
3272 }
3273
3274 /// Helper: wake a task whose status has changed. Called from the
3275 /// method body outside the lock.
3276 async fn wake_task(&self, task_id: &StepId) -> Result<(), EngineError> {
3277 let task_id = task_id.clone();
3278 let notify_opt = self
3279 .with_state("wake_task.get_notify", move |s| {
3280 s.task_notifies.get(&task_id).cloned()
3281 })
3282 .await?;
3283 if let Some(n) = notify_opt {
3284 n.notify_waiters();
3285 }
3286 Ok(())
3287 }
3288}
3289
3290/// Decide what a submit-time projection sink should do at a fail-open
3291/// branch given the configured [`crate::core::config::CheckPolicy`].
3292///
3293/// Returns `Ok(())` under [`CheckPolicy::Silent`] and
3294/// [`CheckPolicy::Warn`] — the caller continues with fail-open. Returns
3295/// [`EngineError::CheckPolicyStrict`] under [`CheckPolicy::Strict`],
3296/// carrying the caller-supplied `context` (call-site identifier) and
3297/// `message` (the pre-existing warn-log message literal, preserved
3298/// verbatim for log parse compatibility).
3299///
3300/// This helper deliberately does **not** call `tracing::warn!` itself —
3301/// the caller is responsible for firing the existing warn! (with its
3302/// full structured-field payload — `%task_id`, `agent`, `canonical`,
3303/// `error`, etc.) under `Warn` mode, and for skipping the warn! under
3304/// `Silent` mode. Keeping the warn! at the call site preserves the
3305/// exact structured-field shape every existing log-parse consumer sees;
3306/// forwarding it through the helper would either drop those fields or
3307/// require a macro (deferred, see subtask-1b).
3308///
3309/// Design intent: the fail-open discipline of every submit-time
3310/// projection sink is byte-identical to the pre-`CheckPolicy` behaviour
3311/// under the default [`CheckPolicy::Warn`]. `Silent` is a per-run opt-in
3312/// to suppress noise (e.g., a caller that has already verified upstream
3313/// invariants); `Strict` is a per-run opt-in to fail loudly (e.g., a
3314/// caller that requires all parts to materialize). See
3315/// [`crate::core::config::CheckPolicy`] for the "state dirty on fail"
3316/// semantics of `Strict`.
3317pub(crate) fn apply_check_policy(
3318 policy: crate::core::config::CheckPolicy,
3319 context: &str,
3320 message: &str,
3321) -> Result<(), EngineError> {
3322 match policy {
3323 crate::core::config::CheckPolicy::Silent | crate::core::config::CheckPolicy::Warn => Ok(()),
3324 crate::core::config::CheckPolicy::Strict => Err(EngineError::CheckPolicyStrict {
3325 context: context.to_string(),
3326 message: message.to_string(),
3327 }),
3328 }
3329}
3330
3331#[cfg(test)]
3332mod check_policy_helper_tests {
3333 use super::apply_check_policy;
3334 use crate::core::config::CheckPolicy;
3335 use crate::core::errors::EngineError;
3336
3337 /// `Silent` returns `Ok(())` without producing an error. Log
3338 /// suppression (the "no `tracing::warn!`" half of the semantics) is
3339 /// enforced at the call site, not inside the helper — see the
3340 /// helper's doc comment for why.
3341 #[test]
3342 fn silent_returns_ok() {
3343 let result = apply_check_policy(CheckPolicy::Silent, "call/site", "sink message");
3344 assert!(matches!(result, Ok(())));
3345 }
3346
3347 /// `Warn` (the default) returns `Ok(())` — the caller continues
3348 /// with fail-open, having already fired its own `tracing::warn!`
3349 /// with the full structured-field payload.
3350 #[test]
3351 fn warn_returns_ok() {
3352 let result = apply_check_policy(CheckPolicy::Warn, "call/site", "sink message");
3353 assert!(matches!(result, Ok(())));
3354 }
3355
3356 /// `Strict` returns
3357 /// [`EngineError::CheckPolicyStrict`] with `context` and `message`
3358 /// copied verbatim from the caller — the completion route surfaces
3359 /// this as a step / launch error so a caller that has opted in can
3360 /// fail fast instead of proceeding with a partially-realized
3361 /// submission.
3362 #[test]
3363 fn strict_returns_error_with_context_and_message() {
3364 let result = apply_check_policy(
3365 CheckPolicy::Strict,
3366 "submit-time projection sink: file materialize",
3367 "no work_dir/project_root resolved; skipping file materialize (fail-open)",
3368 );
3369 match result {
3370 Err(EngineError::CheckPolicyStrict { context, message }) => {
3371 assert_eq!(context, "submit-time projection sink: file materialize");
3372 assert_eq!(
3373 message,
3374 "no work_dir/project_root resolved; skipping file materialize (fail-open)"
3375 );
3376 }
3377 other => panic!("expected CheckPolicyStrict, got {:?}", other),
3378 }
3379 }
3380}
3381
3382// ─── UT: issue #14 — token store keyed by fingerprint, not nonce ────────────
3383#[cfg(test)]
3384mod token_fingerprint_store_tests {
3385 use super::*;
3386
3387 /// A token that was never attached fails verify with a `TokenNotFound`
3388 /// that carries the fingerprint — never the nonce. The error string can
3389 /// surface in HTTP error bodies, so this is the secret-hygiene contract.
3390 #[tokio::test]
3391 async fn verify_unknown_token_reports_fingerprint_not_nonce() {
3392 let engine = Engine::new(EngineCfg::default());
3393 // Signed by the engine's own signer (sig passes) but never inserted
3394 // into the store — verify must fail at step (4), the store lookup.
3395 let token = engine.signer().session(
3396 "ghost",
3397 Role::Operator,
3398 vec!["*".into()],
3399 Duration::from_secs(60),
3400 );
3401 let err = engine
3402 .verify_token(&token, Verb::ReadTaskState)
3403 .await
3404 .expect_err("token is not in the store");
3405 let msg = err.to_string();
3406 assert!(
3407 msg.contains(&token.fingerprint()),
3408 "error must carry the fingerprint: {msg}"
3409 );
3410 assert!(
3411 !msg.contains(&token.nonce),
3412 "error must not leak the nonce: {msg}"
3413 );
3414 }
3415
3416 /// attach → verify → heartbeat → detach all resolve the session /
3417 /// token record through fingerprint keys (mint/verify lifecycle
3418 /// regression guard for the issue #14 key migration).
3419 #[tokio::test]
3420 async fn attach_verify_heartbeat_detach_cycle_with_fp_keying() {
3421 let engine = Engine::new(EngineCfg::default());
3422 let token = engine
3423 .attach("op-1", Role::Operator, Duration::from_secs(60))
3424 .await
3425 .expect("attach");
3426 engine
3427 .verify_token(&token, Verb::ReadTaskState)
3428 .await
3429 .expect("verify consumes via fp key");
3430 engine
3431 .heartbeat(&token)
3432 .await
3433 .expect("heartbeat finds the session by fp");
3434 engine
3435 .detach(&token)
3436 .await
3437 .expect("detach finds the session by fp");
3438 }
3439}
3440
3441// ─── UT: `OperatorKind` "Runtime Global" tier — `Option` semantics ─────────
3442//
3443// Regression coverage for the "explicit Automate is indistinguishable from
3444// unspecified" defect: `OperatorSession.operator_kind` (and the
3445// `attach_with_ids` `kind` parameter it stores) is `Option<OperatorKind>`,
3446// so `Some(Automate)` is an explicit Runtime Global request that must
3447// outrank `bp_global`, while `None` must let `bp_global` decide. Exercises
3448// the real `resolve_operator_info` cascade path (not just
3449// `collapse_operator_kind` in isolation), attaching via `attach_with_ids`
3450// exactly as `TaskLaunchService::launch` does.
3451#[cfg(test)]
3452mod resolve_operator_info_runtime_global_tests {
3453 use super::*;
3454
3455 async fn attach_and_resolve(
3456 runtime_global: Option<OperatorKind>,
3457 bp_global: Option<OperatorKind>,
3458 ) -> OperatorInfo {
3459 let engine = Engine::new(EngineCfg::default());
3460 let token = engine
3461 .attach_with_ids(
3462 "ut-op",
3463 Role::Operator,
3464 Duration::from_secs(30),
3465 runtime_global,
3466 None,
3467 None,
3468 None,
3469 HashMap::new(),
3470 HashMap::new(),
3471 bp_global,
3472 )
3473 .await
3474 .expect("attach_with_ids ok");
3475 let session = engine
3476 .with_state("test.find_session", |s| {
3477 s.sessions
3478 .values()
3479 .find(|sess| sess.token_fp == token.fingerprint())
3480 .cloned()
3481 })
3482 .await
3483 .expect("with_state ok")
3484 .expect("session present after attach_with_ids");
3485 engine.resolve_operator_info(&session, "agent-x").await
3486 }
3487
3488 #[tokio::test]
3489 async fn explicit_some_automate_outranks_bp_global_main_ai() {
3490 // Runtime Global explicitly requests Automate; bp_global is MainAi.
3491 // The explicit `Some(Automate)` must win — this is exactly the case
3492 // the old `== OperatorKind::default()` convention got wrong (it
3493 // could not tell "explicitly Automate" from "unspecified" and would
3494 // have let `bp_global` (MainAi) take over instead).
3495 let info =
3496 attach_and_resolve(Some(OperatorKind::Automate), Some(OperatorKind::MainAi)).await;
3497 assert_eq!(
3498 info.kind,
3499 OperatorKind::Automate,
3500 "explicit Some(Automate) runtime_global must outrank bp_global MainAi"
3501 );
3502 }
3503
3504 #[tokio::test]
3505 async fn none_lets_bp_global_main_ai_win() {
3506 // Runtime Global left unspecified (`None`); bp_global is MainAi.
3507 // With nothing more specific set, `bp_global` must decide.
3508 let info = attach_and_resolve(None, Some(OperatorKind::MainAi)).await;
3509 assert_eq!(
3510 info.kind,
3511 OperatorKind::MainAi,
3512 "None runtime_global must let bp_global MainAi win"
3513 );
3514 }
3515}
3516
3517/// issue #13 run_id propagation: `dispatch_attempt_with`'s `run_id` param
3518/// must land in `Ctx.meta.runtime["run_id"]` (the same slot pattern as the
3519/// pre-existing `worker_handle`), or be omitted entirely when `None`. Same
3520/// `CtxProbe` shape as `middleware::worker_binding`'s test module — an
3521/// inner `SpawnerAdapter` that snapshots the `Ctx` it was called with and
3522/// fails the spawn (only the ctx snapshot matters here).
3523#[cfg(test)]
3524mod dispatch_attempt_with_run_id_tests {
3525 use super::*;
3526 use crate::worker::adapter::{SpawnError, SpawnerAdapter};
3527 use crate::worker::Worker;
3528 use std::sync::Mutex as StdMutex;
3529
3530 struct CtxProbe {
3531 seen: Arc<StdMutex<Option<Ctx>>>,
3532 }
3533
3534 #[async_trait::async_trait]
3535 impl SpawnerAdapter for CtxProbe {
3536 async fn spawn(
3537 &self,
3538 _engine: &Engine,
3539 ctx: &Ctx,
3540 _task_id: StepId,
3541 _attempt: u32,
3542 _token: CapToken,
3543 ) -> Result<Box<dyn Worker>, SpawnError> {
3544 *self.seen.lock().unwrap() = Some(ctx.clone());
3545 Err(SpawnError::Internal("probe stop".into()))
3546 }
3547 }
3548
3549 async fn dispatch_with_probe(run_id: Option<&RunId>) -> Ctx {
3550 let engine = Engine::new(EngineCfg::default());
3551 let token = engine
3552 .attach("ut-op", Role::Operator, Duration::from_secs(30))
3553 .await
3554 .expect("attach");
3555 let tid = engine
3556 .start_task(
3557 &token,
3558 TaskSpec {
3559 agent: "probe".into(),
3560 initial_directive: "hi".into(),
3561 step_ctx: None,
3562 check_policy: None,
3563 },
3564 )
3565 .await
3566 .expect("start_task");
3567 let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
3568 let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
3569 // The probe always errors the spawn (`SpawnError::Internal`); we
3570 // only care about the `Ctx` snapshot it captured, so the dispatch
3571 // outcome itself (`Err`) is discarded.
3572 let _ = engine
3573 .dispatch_attempt_with(&token, &tid, &spawner, run_id)
3574 .await;
3575 let captured = seen.lock().unwrap().clone();
3576 captured.expect("inner ctx captured")
3577 }
3578
3579 #[tokio::test]
3580 async fn run_id_lands_in_ctx_meta_runtime_when_some() {
3581 let run_id = RunId::new();
3582 let observed = dispatch_with_probe(Some(&run_id)).await;
3583 assert_eq!(
3584 observed.meta.runtime.get("run_id").and_then(|v| v.as_str()),
3585 Some(run_id.as_str()),
3586 "ctx.meta.runtime[\"run_id\"] must carry the run_id passed to dispatch_attempt_with"
3587 );
3588 }
3589
3590 #[tokio::test]
3591 async fn run_id_key_absent_when_none() {
3592 let observed = dispatch_with_probe(None).await;
3593 assert!(
3594 !observed.meta.runtime.contains_key("run_id"),
3595 "no run_id key must be injected when dispatch_attempt_with is called with None"
3596 );
3597 }
3598}
3599
3600/// GH #21 Phase 2: `TaskSpec.step_ctx` must land in
3601/// `Ctx.meta.runtime[STEP_CTX_KEY]` — re-read from the spec on EVERY
3602/// attempt (the prep closure re-reads `task.spec.step_ctx` every call, not
3603/// caching it once at `start_task`), so a retry (attempt 2) carries it
3604/// too. Same `CtxProbe` shape as `dispatch_attempt_with_run_id_tests`.
3605#[cfg(test)]
3606mod dispatch_attempt_with_step_ctx_tests {
3607 use super::*;
3608 use crate::worker::adapter::{SpawnError, SpawnerAdapter};
3609 use crate::worker::Worker;
3610 use std::sync::Mutex as StdMutex;
3611
3612 struct CtxProbe {
3613 seen: Arc<StdMutex<Option<Ctx>>>,
3614 }
3615
3616 #[async_trait::async_trait]
3617 impl SpawnerAdapter for CtxProbe {
3618 async fn spawn(
3619 &self,
3620 _engine: &Engine,
3621 ctx: &Ctx,
3622 _task_id: StepId,
3623 _attempt: u32,
3624 _token: CapToken,
3625 ) -> Result<Box<dyn Worker>, SpawnError> {
3626 *self.seen.lock().unwrap() = Some(ctx.clone());
3627 Err(SpawnError::Internal("probe stop".into()))
3628 }
3629 }
3630
3631 #[tokio::test]
3632 async fn step_ctx_lands_in_ctx_meta_runtime_on_attempt_1_and_2() {
3633 let engine = Engine::new(EngineCfg::default());
3634 let token = engine
3635 .attach("ut-op", Role::Operator, Duration::from_secs(30))
3636 .await
3637 .expect("attach");
3638 let tid = engine
3639 .start_task(
3640 &token,
3641 TaskSpec {
3642 agent: "probe".into(),
3643 initial_directive: "hi".into(),
3644 step_ctx: Some(serde_json::json!({ "work_dir": "/step" })),
3645 check_policy: None,
3646 },
3647 )
3648 .await
3649 .expect("start_task");
3650 let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
3651 let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
3652
3653 // The probe always errors the spawn; only the ctx snapshot matters.
3654 let _ = engine
3655 .dispatch_attempt_with(&token, &tid, &spawner, None)
3656 .await;
3657 let first = seen
3658 .lock()
3659 .unwrap()
3660 .clone()
3661 .expect("attempt 1 ctx captured");
3662 assert_eq!(
3663 first.meta.runtime.get(STEP_CTX_KEY),
3664 Some(&serde_json::json!({ "work_dir": "/step" })),
3665 "attempt 1 must carry TaskSpec.step_ctx in ctx.meta.runtime[STEP_CTX_KEY]"
3666 );
3667
3668 let _ = engine
3669 .dispatch_attempt_with(&token, &tid, &spawner, None)
3670 .await;
3671 let second = seen
3672 .lock()
3673 .unwrap()
3674 .clone()
3675 .expect("attempt 2 ctx captured");
3676 assert_eq!(
3677 second.meta.runtime.get(STEP_CTX_KEY),
3678 Some(&serde_json::json!({ "work_dir": "/step" })),
3679 "attempt 2 (retry) must ALSO carry TaskSpec.step_ctx — prep re-reads the spec every attempt"
3680 );
3681 }
3682
3683 #[tokio::test]
3684 async fn step_ctx_key_absent_when_none() {
3685 let engine = Engine::new(EngineCfg::default());
3686 let token = engine
3687 .attach("ut-op", Role::Operator, Duration::from_secs(30))
3688 .await
3689 .expect("attach");
3690 let tid = engine
3691 .start_task(
3692 &token,
3693 TaskSpec {
3694 agent: "probe".into(),
3695 initial_directive: "hi".into(),
3696 step_ctx: None,
3697 check_policy: None,
3698 },
3699 )
3700 .await
3701 .expect("start_task");
3702 let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
3703 let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
3704 let _ = engine
3705 .dispatch_attempt_with(&token, &tid, &spawner, None)
3706 .await;
3707 let observed = seen.lock().unwrap().clone().expect("ctx captured");
3708 assert!(
3709 !observed.meta.runtime.contains_key(STEP_CTX_KEY),
3710 "no step_ctx key must be injected when TaskSpec.step_ctx is None"
3711 );
3712 }
3713}
3714
3715// ─── issue #18: `TaskSpec.initial_directive` `Value` pass-through ──────────
3716#[cfg(test)]
3717mod initial_directive_value_passthrough_tests {
3718 use super::*;
3719
3720 async fn seeded_engine(initial_directive: Value) -> (Engine, CapToken, StepId) {
3721 let engine = Engine::new(EngineCfg::default());
3722 let op_token = engine
3723 .attach("ut-op", Role::Operator, Duration::from_secs(30))
3724 .await
3725 .expect("attach");
3726 let task_id = engine
3727 .start_task(
3728 &op_token,
3729 TaskSpec {
3730 agent: "planner".to_string(),
3731 initial_directive,
3732 step_ctx: None,
3733 check_policy: None,
3734 },
3735 )
3736 .await
3737 .expect("start_task");
3738 (engine, op_token, task_id)
3739 }
3740
3741 /// Mint + register a `Role::Worker` token the same way
3742 /// `dispatch_attempt_with` does — `fetch_prompt` is worker-verb-gated.
3743 async fn mint_worker_token(engine: &Engine, task_id: &StepId) -> CapToken {
3744 let worker_token = engine.signer().session(
3745 format!("worker-of-{task_id}"),
3746 Role::Worker,
3747 vec!["*".into()],
3748 Duration::from_secs(600),
3749 );
3750 let fp = worker_token.fingerprint();
3751 let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
3752 engine
3753 .with_state("test.mint_worker", move |s| {
3754 s.tokens.insert(fp, record);
3755 })
3756 .await
3757 .expect("mint worker token");
3758 worker_token
3759 }
3760
3761 /// `EngineDispatcher::dispatch` no longer stringifies the evaluated
3762 /// `Step.in` value before seeding `TaskSpec.initial_directive` — an
3763 /// Object seed must round-trip through `start_task` /
3764 /// `read_task_state` byte-for-byte as the same `Value::Object`, not a
3765 /// JSON-stringified `Value::String`.
3766 #[tokio::test]
3767 async fn object_seed_passes_through_task_spec_unchanged() {
3768 let seed = serde_json::json!({"key": "value"});
3769 let (engine, token, task_id) = seeded_engine(seed.clone()).await;
3770 let state = engine
3771 .read_task_state(&token, &task_id)
3772 .await
3773 .expect("read_task_state");
3774 assert_eq!(
3775 state.spec.initial_directive, seed,
3776 "TaskSpec.initial_directive must equal the raw Object seed, not a stringified copy"
3777 );
3778 }
3779
3780 /// `Engine::fetch_prompt` returns the `Value` end-to-end (issue #18):
3781 /// an Object seed stays a `Value::Object` and is not stringified in
3782 /// the engine layer. The Worker HTTP boundary
3783 /// (`fetch_worker_payload*`) is what performs the render down to a
3784 /// JSON literal `String` for `WorkerPayload.prompt`.
3785 #[tokio::test]
3786 async fn object_seed_passes_through_fetch_prompt_as_value() {
3787 let seed = serde_json::json!({"key": "value"});
3788 let (engine, _token, task_id) = seeded_engine(seed.clone()).await;
3789 let worker_token = mint_worker_token(&engine, &task_id).await;
3790 let prompt = engine
3791 .fetch_prompt(&worker_token, &task_id)
3792 .await
3793 .expect("fetch_prompt");
3794 assert_eq!(
3795 prompt, seed,
3796 "fetch_prompt must return the raw Object Value, not a stringified copy"
3797 );
3798 }
3799
3800 /// The Worker HTTP boundary is the render point: `fetch_worker_payload*`
3801 /// coerces the stored `Value` down to `WorkerPayload.prompt: String`
3802 /// (JSON-literal shape for non-strings). Verifies the boundary render
3803 /// stays intact for an Object seed.
3804 #[tokio::test]
3805 async fn object_seed_renders_as_json_literal_at_worker_payload_boundary() {
3806 let seed = serde_json::json!({"key": "value"});
3807 let (engine, _token, task_id) = seeded_engine(seed).await;
3808 let worker_token = mint_worker_token(&engine, &task_id).await;
3809 let payload = engine
3810 .fetch_worker_payload(&worker_token, &task_id)
3811 .await
3812 .expect("fetch_worker_payload");
3813 assert_eq!(
3814 payload.prompt, r#"{"key":"value"}"#,
3815 "WorkerPayload.prompt must be the JSON literal String render of the Value seed"
3816 );
3817 }
3818
3819 /// A `String` seed is unaffected — still passes through verbatim, both
3820 /// as the `TaskSpec.initial_directive` `Value` and as the Worker
3821 /// `fetch_prompt` return (issue #18 Invariant 2).
3822 #[tokio::test]
3823 async fn string_seed_passes_through_unchanged() {
3824 let (engine, token, task_id) = seeded_engine(serde_json::json!("do the thing")).await;
3825 let state = engine
3826 .read_task_state(&token, &task_id)
3827 .await
3828 .expect("read_task_state");
3829 assert_eq!(
3830 state.spec.initial_directive,
3831 serde_json::json!("do the thing")
3832 );
3833 let worker_token = mint_worker_token(&engine, &task_id).await;
3834 let prompt = engine
3835 .fetch_prompt(&worker_token, &task_id)
3836 .await
3837 .expect("fetch_prompt");
3838 assert_eq!(prompt, serde_json::json!("do the thing"));
3839 }
3840}
3841
3842/// GH #31: `fetch_worker_payload{,_trusted}`'s size-threshold branch
3843/// between inline (`WorkerPayload.system`) and by-reference
3844/// (`WorkerPayload.system_ref`) delivery, plus the `bake_worker_system_prompt`
3845/// `agent_render_sizes` bookkeeping that feeds `agent_last_rendered_size`.
3846#[cfg(test)]
3847mod system_ref_threshold_tests {
3848 use super::*;
3849
3850 async fn seeded_engine_with_cfg(cfg: EngineCfg) -> (Engine, CapToken, StepId) {
3851 let engine = Engine::new(cfg);
3852 let op_token = engine
3853 .attach("ut-op", Role::Operator, Duration::from_secs(30))
3854 .await
3855 .expect("attach");
3856 let task_id = engine
3857 .start_task(
3858 &op_token,
3859 TaskSpec {
3860 agent: "planner".to_string(),
3861 initial_directive: serde_json::json!("do the thing"),
3862 step_ctx: None,
3863 check_policy: None,
3864 },
3865 )
3866 .await
3867 .expect("start_task");
3868 (engine, op_token, task_id)
3869 }
3870
3871 /// Same worker-token-minting fixture as
3872 /// `initial_directive_value_passthrough_tests::mint_worker_token`
3873 /// (kept local to this module — the two `mod`s do not share private
3874 /// helpers across `cfg(test)` boundaries).
3875 async fn mint_worker_token(engine: &Engine, task_id: &StepId) -> CapToken {
3876 let worker_token = engine.signer().session(
3877 format!("worker-of-{task_id}"),
3878 Role::Worker,
3879 vec!["*".into()],
3880 Duration::from_secs(600),
3881 );
3882 let fp = worker_token.fingerprint();
3883 let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
3884 engine
3885 .with_state("test.mint_worker", move |s| {
3886 s.tokens.insert(fp, record);
3887 })
3888 .await
3889 .expect("mint worker token");
3890 worker_token
3891 }
3892
3893 /// Under-threshold: `system` stays inline, `system_ref` stays `None`.
3894 #[tokio::test]
3895 async fn under_threshold_stays_inline() {
3896 let (engine, _op_token, task_id) = seeded_engine_with_cfg(EngineCfg::default()).await;
3897 let worker_token = mint_worker_token(&engine, &task_id).await;
3898 let rendered = "a short system prompt".to_string();
3899 engine
3900 .bake_worker_system_prompt(&task_id, 1, Some(rendered.clone()))
3901 .await
3902 .expect("bake");
3903 let payload = engine
3904 .fetch_worker_payload(&worker_token, &task_id)
3905 .await
3906 .expect("fetch_worker_payload");
3907 assert_eq!(payload.system, Some(rendered));
3908 assert!(payload.system_ref.is_none());
3909 }
3910
3911 /// Over-threshold: `system` is cleared and `system_ref` is populated
3912 /// with a `sha256` matching the known input string. Exercises
3913 /// `fetch_worker_payload_trusted` (the `_trusted` sibling must be
3914 /// behaviorally identical to `fetch_worker_payload`).
3915 #[tokio::test]
3916 async fn over_threshold_switches_to_system_ref_with_matching_sha256() {
3917 let mut cfg = EngineCfg::default();
3918 cfg.system_ref.threshold_bytes = 16;
3919 cfg.system_ref.mode = crate::types::SystemRefMode::File;
3920 cfg.system_ref.store_dir =
3921 std::env::temp_dir().join(format!("mse-system-ref-test-{}", crate::types::now_unix()));
3922 let (engine, _op_token, task_id) = seeded_engine_with_cfg(cfg).await;
3923 let rendered =
3924 "this system prompt is deliberately longer than the 16 byte threshold".to_string();
3925 engine
3926 .bake_worker_system_prompt(&task_id, 1, Some(rendered.clone()))
3927 .await
3928 .expect("bake");
3929 let payload = engine
3930 .fetch_worker_payload_trusted(&task_id)
3931 .await
3932 .expect("fetch_worker_payload_trusted");
3933 assert!(
3934 payload.system.is_none(),
3935 "over-threshold response must not also inline `system`"
3936 );
3937 let system_ref = payload
3938 .system_ref
3939 .expect("over-threshold response must populate system_ref");
3940 assert_eq!(system_ref.size_bytes, rendered.len() as u64);
3941 assert_eq!(system_ref.mode, crate::types::SystemRefMode::File);
3942 use sha2::Digest;
3943 let expected_sha256 = hex::encode(sha2::Sha256::digest(rendered.as_bytes()));
3944 assert_eq!(system_ref.sha256, expected_sha256);
3945 assert!(system_ref.uri.starts_with("file://"));
3946 let written = tokio::fs::read_to_string(system_ref.uri.trim_start_matches("file://"))
3947 .await
3948 .expect("File mode must have written the referenced path");
3949 assert_eq!(written, rendered);
3950 }
3951
3952 /// `Http` mode never writes a file — `system_ref.uri` is the bare path
3953 /// the engine can construct on its own, scheme/host-free.
3954 #[tokio::test]
3955 async fn over_threshold_http_mode_constructs_path_only_uri() {
3956 let mut cfg = EngineCfg::default();
3957 cfg.system_ref.threshold_bytes = 16;
3958 cfg.system_ref.mode = crate::types::SystemRefMode::Http;
3959 let (engine, _op_token, task_id) = seeded_engine_with_cfg(cfg).await;
3960 let worker_token = mint_worker_token(&engine, &task_id).await;
3961 let rendered =
3962 "this system prompt is deliberately longer than the 16 byte threshold".to_string();
3963 engine
3964 .bake_worker_system_prompt(&task_id, 1, Some(rendered))
3965 .await
3966 .expect("bake");
3967 let payload = engine
3968 .fetch_worker_payload(&worker_token, &task_id)
3969 .await
3970 .expect("fetch_worker_payload");
3971 let system_ref = payload.system_ref.expect("system_ref must be populated");
3972 assert_eq!(system_ref.mode, crate::types::SystemRefMode::Http);
3973 assert_eq!(
3974 system_ref.uri,
3975 format!("/v1/worker/prompt/system?task_id={task_id}&attempt=1")
3976 );
3977 }
3978
3979 /// `bake_worker_system_prompt` records the render size keyed by agent
3980 /// name (last-write-wins), readable via `agent_last_rendered_size`.
3981 #[tokio::test]
3982 async fn bake_records_agent_render_size_last_write_wins() {
3983 let (engine, _op_token, task_id) = seeded_engine_with_cfg(EngineCfg::default()).await;
3984 assert_eq!(engine.agent_last_rendered_size("planner").await, None);
3985 engine
3986 .bake_worker_system_prompt(&task_id, 1, Some("a".repeat(10)))
3987 .await
3988 .expect("bake 1");
3989 assert_eq!(engine.agent_last_rendered_size("planner").await, Some(10));
3990 engine
3991 .bake_worker_system_prompt(&task_id, 2, Some("b".repeat(20)))
3992 .await
3993 .expect("bake 2");
3994 assert_eq!(
3995 engine.agent_last_rendered_size("planner").await,
3996 Some(20),
3997 "most-recently-observed size wins, not the largest"
3998 );
3999 }
4000
4001 /// GH #83: `materialize_system_file` writes the baked system prompt
4002 /// unconditionally — a system well UNDER `threshold_bytes` still
4003 /// lands on disk, because a `{system_file}` template reference needs
4004 /// a real path regardless of size.
4005 #[tokio::test]
4006 async fn materialize_system_file_writes_under_threshold_system() {
4007 let mut cfg = EngineCfg::default();
4008 cfg.system_ref.store_dir =
4009 std::env::temp_dir().join(format!("mse-system-file-test-{}", crate::types::now_unix()));
4010 assert!(cfg.system_ref.threshold_bytes > 64, "fixture premise");
4011 let (engine, _op_token, task_id) = seeded_engine_with_cfg(cfg).await;
4012 let rendered = "a short system prompt".to_string();
4013 engine
4014 .bake_worker_system_prompt(&task_id, 1, Some(rendered.clone()))
4015 .await
4016 .expect("bake");
4017 let path = engine
4018 .materialize_system_file(&task_id, 1)
4019 .await
4020 .expect("materialize_system_file")
4021 .expect("baked system must yield a path");
4022 let written = tokio::fs::read_to_string(&path)
4023 .await
4024 .expect("materialized path must exist");
4025 assert_eq!(written, rendered);
4026 }
4027
4028 /// GH #83: no baked system → `Ok(None)` (the Subprocess spawn path
4029 /// turns this into a fail-loud `SpawnError` when `{system_file}` is
4030 /// actually referenced).
4031 #[tokio::test]
4032 async fn materialize_system_file_none_when_nothing_baked() {
4033 let (engine, _op_token, task_id) = seeded_engine_with_cfg(EngineCfg::default()).await;
4034 let path = engine
4035 .materialize_system_file(&task_id, 1)
4036 .await
4037 .expect("materialize_system_file");
4038 assert!(path.is_none());
4039 }
4040}
4041
4042/// subtask-4 / ST2 rework: `submit_output` / `submit_worker_result_trusted`'s
4043/// submit-time projection sink (`Engine::materialize_final_submission`) —
4044/// the Data-plane `OutputStore` dual-write plus the
4045/// `FileProjectionAdapter`-backed file materialize, both fail-open. See
4046/// the subtask-4 Tests this module covers inline on each test.
4047#[cfg(test)]
4048mod submit_time_projection_sink_tests {
4049 use super::*;
4050 use crate::core::agent_context::AgentContextView;
4051 use crate::store::output::{ContentRef, InMemoryOutputStore, OutputEvent};
4052
4053 /// Starts a task under `agent`, returning `(engine, op_token, task_id,
4054 /// worker_token)` — same helper shape as the sibling test modules
4055 /// above (`initial_directive_value_passthrough_tests::seeded_engine` /
4056 /// `mint_worker_token`), duplicated locally per this file's
4057 /// established per-module convention.
4058 async fn seeded_task(agent: &str) -> (Engine, CapToken, StepId, CapToken) {
4059 let engine = Engine::new(EngineCfg::default());
4060 let op_token = engine
4061 .attach("ut-op", Role::Operator, Duration::from_secs(30))
4062 .await
4063 .expect("attach");
4064 let task_id = engine
4065 .start_task(
4066 &op_token,
4067 TaskSpec {
4068 agent: agent.to_string(),
4069 initial_directive: Value::String("go".into()),
4070 step_ctx: None,
4071 check_policy: None,
4072 },
4073 )
4074 .await
4075 .expect("start_task");
4076 let worker_token = engine.signer().session(
4077 format!("worker-of-{task_id}"),
4078 Role::Worker,
4079 vec!["*".into()],
4080 Duration::from_secs(600),
4081 );
4082 let fp = worker_token.fingerprint();
4083 let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
4084 engine
4085 .with_state("test.mint_worker", move |s| {
4086 s.tokens.insert(fp, record);
4087 })
4088 .await
4089 .expect("mint worker token");
4090 (engine, op_token, task_id, worker_token)
4091 }
4092
4093 /// Sibling of [`seeded_task`] that lets a caller pin the engine's
4094 /// `EngineCfg.check_policy` before the engine is constructed — used
4095 /// by the `check_policy_*` regression tests below to exercise the
4096 /// three [`crate::core::config::CheckPolicy`] modes without touching
4097 /// the shared `seeded_task` helper (which every unrelated sink test
4098 /// depends on).
4099 async fn seeded_task_with_policy(
4100 agent: &str,
4101 policy: crate::core::config::CheckPolicy,
4102 ) -> (Engine, CapToken, StepId, CapToken) {
4103 let cfg = EngineCfg {
4104 check_policy: policy,
4105 ..EngineCfg::default()
4106 };
4107 let engine = Engine::new(cfg);
4108 let op_token = engine
4109 .attach("ut-op", Role::Operator, Duration::from_secs(30))
4110 .await
4111 .expect("attach");
4112 let task_id = engine
4113 .start_task(
4114 &op_token,
4115 TaskSpec {
4116 agent: agent.to_string(),
4117 initial_directive: Value::String("go".into()),
4118 step_ctx: None,
4119 check_policy: None,
4120 },
4121 )
4122 .await
4123 .expect("start_task");
4124 let worker_token = engine.signer().session(
4125 format!("worker-of-{task_id}"),
4126 Role::Worker,
4127 vec!["*".into()],
4128 Duration::from_secs(600),
4129 );
4130 let fp = worker_token.fingerprint();
4131 let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
4132 engine
4133 .with_state("test.mint_worker", move |s| {
4134 s.tokens.insert(fp, record);
4135 })
4136 .await
4137 .expect("mint worker token");
4138 (engine, op_token, task_id, worker_token)
4139 }
4140
4141 /// Seeds `EngineState.agent_ctx[(task_id, attempt)].view` directly —
4142 /// the same snapshot `AgentContextMiddleware` writes at spawn time
4143 /// (see its module doc), stood up here without the full spawner
4144 /// stack so these tests can exercise `submit_output` in isolation.
4145 async fn seed_agent_context(engine: &Engine, task_id: &StepId, attempt: u32, work_dir: &str) {
4146 let task_id = task_id.clone();
4147 let work_dir = work_dir.to_string();
4148 engine
4149 .with_state("test.seed_agent_context", move |s| {
4150 s.agent_ctx.insert(
4151 (task_id, attempt),
4152 crate::core::state::AgentCtxEntry {
4153 view: AgentContextView {
4154 work_dir: Some(work_dir),
4155 ..Default::default()
4156 },
4157 policy: Default::default(),
4158 },
4159 );
4160 })
4161 .await
4162 .expect("seed agent_ctx");
4163 }
4164
4165 /// GH #27 (follow-up to #23): seeds `EngineState.agent_ctx` with an
4166 /// arbitrary `work_dir` / `project_root` pair (either may be `None`),
4167 /// unlike [`seed_agent_context`] (which only ever sets `work_dir`) —
4168 /// lets these tests exercise `ProjectionPlacement::resolve_root`'s
4169 /// fallback in both directions.
4170 async fn seed_agent_context_roots(
4171 engine: &Engine,
4172 task_id: &StepId,
4173 attempt: u32,
4174 work_dir: Option<&str>,
4175 project_root: Option<&str>,
4176 ) {
4177 let task_id = task_id.clone();
4178 let work_dir = work_dir.map(str::to_string);
4179 let project_root = project_root.map(str::to_string);
4180 engine
4181 .with_state("test.seed_agent_context_roots", move |s| {
4182 s.agent_ctx.insert(
4183 (task_id, attempt),
4184 crate::core::state::AgentCtxEntry {
4185 view: AgentContextView {
4186 work_dir,
4187 project_root,
4188 ..Default::default()
4189 },
4190 policy: Default::default(),
4191 },
4192 );
4193 })
4194 .await
4195 .expect("seed agent_ctx");
4196 }
4197
4198 /// GH #27 (follow-up to #23): seeds `EngineState.projection_placements`
4199 /// directly — the same snapshot `EngineDispatcher::dispatch` stashes
4200 /// at dispatch time (mirroring [`seed_step_naming`]'s contract) — so
4201 /// these tests can exercise a declared `ProjectionPlacement` without
4202 /// driving a real `Compiler::compile`.
4203 async fn seed_projection_placement(
4204 engine: &Engine,
4205 task_id: &StepId,
4206 placement: crate::core::projection_placement::ProjectionPlacement,
4207 ) {
4208 let task_id = task_id.clone();
4209 let placement = Arc::new(placement);
4210 engine
4211 .with_state("test.seed_projection_placement", move |s| {
4212 s.projection_placements.insert(task_id, placement);
4213 })
4214 .await
4215 .expect("seed projection_placements");
4216 }
4217
4218 /// GH #23 subtask-2: builds a fixture
4219 /// [`crate::core::step_naming::StepNaming`] table declaring `producer`
4220 /// → `canonical` (`AgentMeta.projection_name`), then seeds it into
4221 /// `EngineState.step_namings` for `task_id` — the same snapshot
4222 /// `EngineDispatcher::dispatch` stashes at dispatch time
4223 /// (`blueprint.rs`'s "construct once, read many" contract), stood up
4224 /// here without the full Blueprint-compile path so these tests can
4225 /// exercise the canonical-sink resolution in isolation.
4226 async fn seed_step_naming(engine: &Engine, task_id: &StepId, producer: &str, canonical: &str) {
4227 use crate::blueprint::{
4228 current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
4229 CompilerHints, CompilerStrategy,
4230 };
4231 use crate::core::step_naming::StepNaming;
4232 use mlua_flow_ir::{Expr, Node};
4233
4234 let flow = Node::Step {
4235 ref_: producer.to_string(),
4236 in_: Expr::Path {
4237 at: "$.in".parse().expect("literal test path: $.in"),
4238 },
4239 out: Expr::Path {
4240 at: format!("$.{producer}_out")
4241 .parse()
4242 .expect("literal test path"),
4243 },
4244 };
4245 let bp = Blueprint {
4246 schema_version: current_schema_version(),
4247 id: "sink-canonical-ut".into(),
4248 flow,
4249 agents: vec![AgentDef {
4250 name: producer.to_string(),
4251 kind: AgentKind::RustFn,
4252 spec: serde_json::json!({ "fn_id": producer }),
4253 profile: None,
4254 meta: Some(AgentMeta {
4255 projection_name: Some(canonical.to_string()),
4256 ..Default::default()
4257 }),
4258 runner: None,
4259 runner_ref: None,
4260 verdict: None,
4261 }],
4262 operators: vec![],
4263 metas: vec![],
4264 hints: CompilerHints::default(),
4265 strategy: CompilerStrategy::default(),
4266 metadata: BlueprintMetadata::default(),
4267 spawner_hints: Default::default(),
4268 default_agent_kind: AgentKind::Operator,
4269 default_operator_kind: None,
4270 default_init_ctx: None,
4271 default_agent_ctx: None,
4272 default_context_policy: None,
4273 projection_placement: None,
4274 audits: vec![],
4275 degradation_policy: None,
4276 runners: vec![],
4277 default_runner: None,
4278 subprocesses: vec![],
4279 check_policy: None,
4280 blueprint_ref_includes: Vec::new(),
4281 };
4282 let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
4283 assert!(warnings.is_empty(), "single-step fixture has no collisions");
4284 let naming = Arc::new(naming);
4285 let task_id = task_id.clone();
4286 engine
4287 .with_state("test.seed_step_naming", move |s| {
4288 s.step_namings.insert(task_id, naming);
4289 })
4290 .await
4291 .expect("seed step_namings");
4292 }
4293
4294 fn final_event(value: Value, ok: bool) -> crate::worker::output::OutputEvent {
4295 crate::worker::output::OutputEvent::Final {
4296 content: crate::worker::output::ContentRef::Inline { value },
4297 ok,
4298 }
4299 }
4300
4301 /// Subtask 4 Test #2: `submit_output`'s `Final` writes
4302 /// `<root>/workspace/tasks/<task_id>/ctx/<agent>.md`, content matching
4303 /// the submitted value.
4304 #[tokio::test]
4305 async fn submit_output_final_materializes_file_when_work_dir_resolved() {
4306 let dir = tempfile::TempDir::new().unwrap();
4307 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
4308 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4309
4310 engine
4311 .submit_output(
4312 &worker_token,
4313 &task_id,
4314 1,
4315 final_event(serde_json::json!({"plan": "do it"}), true),
4316 )
4317 .await
4318 .expect("submit_output");
4319
4320 let expected_file = dir
4321 .path()
4322 .join("workspace/tasks")
4323 .join(task_id.as_str())
4324 .join("ctx/planner.md");
4325 assert!(
4326 expected_file.exists(),
4327 "materialized submission file missing at {expected_file:?}"
4328 );
4329 let body = std::fs::read_to_string(expected_file).unwrap();
4330 assert!(body.contains(r#""plan": "do it""#), "body: {body}");
4331 }
4332
4333 /// Subtask 4 Test #3: `work_dir` unresolved (no `agent_ctx`
4334 /// snapshot for this `(task_id, attempt)`) — submit still succeeds,
4335 /// fail-open, no file.
4336 #[tokio::test]
4337 async fn submit_output_final_skips_file_when_root_unresolved() {
4338 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
4339 // No seed_agent_context call — root is unresolved.
4340
4341 let result = engine
4342 .submit_output(
4343 &worker_token,
4344 &task_id,
4345 1,
4346 final_event(serde_json::json!("hi"), true),
4347 )
4348 .await;
4349 assert!(
4350 result.is_ok(),
4351 "submit must succeed even with no resolvable root (fail-open, Invariant 1)"
4352 );
4353 }
4354
4355 /// Regression for the check_policy cascade: the default
4356 /// [`crate::core::config::CheckPolicy::Warn`] preserves the
4357 /// pre-`CheckPolicy` fail-open semantics — a submit whose root is
4358 /// unresolved still succeeds. Byte-compat with
4359 /// `submit_output_final_skips_file_when_root_unresolved`; this test
4360 /// pins the mode explicitly so a future default change to
4361 /// `Strict` (silent breakage) is caught here.
4362 #[tokio::test]
4363 async fn submit_output_final_check_policy_warn_preserves_fail_open() {
4364 let (engine, _op, task_id, worker_token) =
4365 seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Warn).await;
4366
4367 let result = engine
4368 .submit_output(
4369 &worker_token,
4370 &task_id,
4371 1,
4372 final_event(serde_json::json!("hi"), true),
4373 )
4374 .await;
4375 assert!(
4376 result.is_ok(),
4377 "Warn mode preserves fail-open: submit must succeed when root unresolved"
4378 );
4379 }
4380
4381 /// Regression for the check_policy cascade:
4382 /// [`crate::core::config::CheckPolicy::Strict`] surfaces the "no
4383 /// work_dir/project_root resolved" fail-open condition as an
4384 /// [`EngineError::CheckPolicyStrict`], letting a caller who has
4385 /// opted in fail fast instead of proceeding with a partially-
4386 /// realized submission. The error's `context` identifies the call
4387 /// site (`"file materialize"`), and `message` preserves the
4388 /// pre-`CheckPolicy` warn literal verbatim (log-parse compat).
4389 #[tokio::test]
4390 async fn submit_output_final_check_policy_strict_surfaces_error_when_root_unresolved() {
4391 let (engine, _op, task_id, worker_token) =
4392 seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Strict).await;
4393
4394 let err = engine
4395 .submit_output(
4396 &worker_token,
4397 &task_id,
4398 1,
4399 final_event(serde_json::json!("hi"), true),
4400 )
4401 .await
4402 .expect_err("Strict mode must return an error when root unresolved");
4403 match err {
4404 EngineError::CheckPolicyStrict { context, message } => {
4405 assert!(
4406 context.contains("file materialize"),
4407 "context must identify the call site: {context}"
4408 );
4409 assert!(
4410 message.contains("no work_dir/project_root resolved"),
4411 "message must preserve the warn-log literal for log-parse compat: {message}"
4412 );
4413 }
4414 other => panic!(
4415 "expected EngineError::CheckPolicyStrict, got a different variant: {other:?}"
4416 ),
4417 }
4418 }
4419
4420 /// Regression for the check_policy cascade:
4421 /// [`crate::core::config::CheckPolicy::Silent`] returns `Ok(())` (
4422 /// like `Warn`) without surfacing an error. The log-suppression side
4423 /// of `Silent` (no `tracing::warn!`) is enforced at the call site
4424 /// via the `if !matches!(policy, Silent) { warn!(...) }` guard —
4425 /// verifying tracing output shape here would couple the test to a
4426 /// subscriber setup, so the assertion is limited to the error-
4427 /// return semantics (matches the helper unit tests in
4428 /// `check_policy_helper_tests`).
4429 #[tokio::test]
4430 async fn submit_output_final_check_policy_silent_returns_ok_when_root_unresolved() {
4431 let (engine, _op, task_id, worker_token) =
4432 seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Silent).await;
4433
4434 let result = engine
4435 .submit_output(
4436 &worker_token,
4437 &task_id,
4438 1,
4439 final_event(serde_json::json!("hi"), true),
4440 )
4441 .await;
4442 assert!(
4443 result.is_ok(),
4444 "Silent mode returns Ok(()) at the error surface: submit must succeed"
4445 );
4446 }
4447
4448 /// Subtask 4 Test #4 (file half): re-submitting under the same
4449 /// `(task_id, agent)` overwrites the materialized file with the
4450 /// latest value.
4451 #[tokio::test]
4452 async fn resubmit_overwrites_materialized_file_with_latest() {
4453 let dir = tempfile::TempDir::new().unwrap();
4454 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
4455 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4456
4457 engine
4458 .submit_output(
4459 &worker_token,
4460 &task_id,
4461 1,
4462 final_event(serde_json::json!("first"), true),
4463 )
4464 .await
4465 .expect("first submit");
4466 engine
4467 .submit_output(
4468 &worker_token,
4469 &task_id,
4470 1,
4471 final_event(serde_json::json!("second"), true),
4472 )
4473 .await
4474 .expect("second submit");
4475
4476 let expected_file = dir
4477 .path()
4478 .join("workspace/tasks")
4479 .join(task_id.as_str())
4480 .join("ctx/planner.md");
4481 let body = std::fs::read_to_string(expected_file).unwrap();
4482 assert!(body.contains("second"), "body must reflect latest: {body}");
4483 assert!(
4484 !body.contains("first"),
4485 "body must not carry the stale value: {body}"
4486 );
4487 }
4488
4489 /// GH #27 (follow-up to #23): the byte-compat default
4490 /// `ProjectionPlacement` (`root_preference = WorkDir`) falls back to
4491 /// `project_root` when `work_dir` is absent — the same fallback
4492 /// [`crate::core::projection_placement::ProjectionPlacement::resolve_root`]
4493 /// now performs for every one of the "3 path" call sites, this one
4494 /// exercised at the submit-sink layer.
4495 #[tokio::test]
4496 async fn submit_output_final_falls_back_to_project_root_when_work_dir_absent() {
4497 let dir = tempfile::TempDir::new().unwrap();
4498 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
4499 seed_agent_context_roots(
4500 &engine,
4501 &task_id,
4502 1,
4503 None,
4504 Some(&dir.path().to_string_lossy()),
4505 )
4506 .await;
4507
4508 engine
4509 .submit_output(
4510 &worker_token,
4511 &task_id,
4512 1,
4513 final_event(serde_json::json!({"plan": "via project_root"}), true),
4514 )
4515 .await
4516 .expect("submit_output");
4517
4518 let expected_file = dir
4519 .path()
4520 .join("workspace/tasks")
4521 .join(task_id.as_str())
4522 .join("ctx/planner.md");
4523 assert!(
4524 expected_file.exists(),
4525 "materialized submission file missing at {expected_file:?} \
4526 (work_dir absent must fall back to project_root)"
4527 );
4528 }
4529
4530 /// GH #27 (follow-up to #23): a declared `ProjectionPlacement`
4531 /// (`root_preference = ProjectRoot`, custom `dir_template`) changes
4532 /// BOTH which root is preferred (project_root wins even though
4533 /// work_dir is also present) AND the target directory layout — proof
4534 /// the submit sink consults the snapshotted resolver rather than a
4535 /// hardcoded layout.
4536 #[tokio::test]
4537 async fn submit_output_final_uses_declared_projection_placement() {
4538 let work_dir = tempfile::TempDir::new().unwrap();
4539 let project_root = tempfile::TempDir::new().unwrap();
4540 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
4541 seed_agent_context_roots(
4542 &engine,
4543 &task_id,
4544 1,
4545 Some(&work_dir.path().to_string_lossy()),
4546 Some(&project_root.path().to_string_lossy()),
4547 )
4548 .await;
4549 seed_projection_placement(
4550 &engine,
4551 &task_id,
4552 crate::core::projection_placement::ProjectionPlacement {
4553 root_preference: crate::core::projection_placement::RootPreference::ProjectRoot,
4554 dir_template: "custom/{task_id}/out".to_string(),
4555 },
4556 )
4557 .await;
4558
4559 engine
4560 .submit_output(
4561 &worker_token,
4562 &task_id,
4563 1,
4564 final_event(serde_json::json!({"plan": "via custom placement"}), true),
4565 )
4566 .await
4567 .expect("submit_output");
4568
4569 let expected_file = project_root
4570 .path()
4571 .join("custom")
4572 .join(task_id.as_str())
4573 .join("out/planner.md");
4574 assert!(
4575 expected_file.exists(),
4576 "materialized submission file missing at custom placement target {expected_file:?}"
4577 );
4578 let unexpected_file = work_dir
4579 .path()
4580 .join("workspace/tasks")
4581 .join(task_id.as_str())
4582 .join("ctx/planner.md");
4583 assert!(
4584 !unexpected_file.exists(),
4585 "declared root_preference=ProjectRoot must not fall back to work_dir: {unexpected_file:?}"
4586 );
4587 }
4588
4589 /// Subtask 4 Invariant 3 / crux requirement #3: when
4590 /// [`Engine::set_output_store`] wires a Data-plane [`crate::store::output::OutputStore`],
4591 /// `submit_output`'s `Final` dual-writes into it under
4592 /// `producer_agent = TaskState.spec.agent` — the store becomes
4593 /// queryable via `get_latest_by_name`, independent of whether a root
4594 /// resolved for the file half.
4595 #[tokio::test]
4596 async fn submit_output_final_dual_writes_into_configured_output_store() {
4597 let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
4598 let data_store: Arc<dyn crate::store::output::OutputStore> =
4599 Arc::new(InMemoryOutputStore::new());
4600 engine.set_output_store(data_store.clone());
4601
4602 engine
4603 .submit_output(
4604 &worker_token,
4605 &task_id,
4606 1,
4607 final_event(serde_json::json!({"verdict": "pass"}), true),
4608 )
4609 .await
4610 .expect("submit_output");
4611
4612 let record = data_store
4613 .get_latest_by_name("reviewer")
4614 .await
4615 .expect("dual-written record");
4616 match record.event {
4617 OutputEvent::Final { content, ok } => {
4618 assert!(ok);
4619 match content {
4620 ContentRef::Inline { value } => {
4621 assert_eq!(value, serde_json::json!({"verdict": "pass"}));
4622 }
4623 other => panic!("expected Inline content, got {other:?}"),
4624 }
4625 }
4626 other => panic!("expected Final event, got {other:?}"),
4627 }
4628 }
4629
4630 /// GH #34 subtask-3 gap fix: an `Artifact` event submitted via
4631 /// `submit_output` dual-writes into a wired Data-plane `OutputStore`
4632 /// under its OWN `name`, verbatim — mirrors
4633 /// `submit_output_final_dual_writes_into_configured_output_store`
4634 /// above, but for the `Artifact` variant.
4635 #[tokio::test]
4636 async fn submit_output_artifact_dual_writes_into_configured_output_store() {
4637 let (engine, _op, task_id, worker_token) = seeded_task("echo").await;
4638 let data_store: Arc<dyn crate::store::output::OutputStore> =
4639 Arc::new(InMemoryOutputStore::new());
4640 engine.set_output_store(data_store.clone());
4641
4642 engine
4643 .submit_output(
4644 &worker_token,
4645 &task_id,
4646 1,
4647 OutputEvent::Artifact {
4648 name: "audit:echo".to_string(),
4649 content: ContentRef::Inline {
4650 value: serde_json::json!({"finding": "clean"}),
4651 },
4652 },
4653 )
4654 .await
4655 .expect("submit_output");
4656
4657 let record = data_store
4658 .get_latest_by_name("audit:echo")
4659 .await
4660 .expect("dual-written artifact record");
4661 match record.event {
4662 OutputEvent::Artifact { name, content } => {
4663 assert_eq!(name, "audit:echo");
4664 match content {
4665 ContentRef::Inline { value } => {
4666 assert_eq!(value, serde_json::json!({"finding": "clean"}));
4667 }
4668 other => panic!("expected Inline content, got {other:?}"),
4669 }
4670 }
4671 other => panic!("expected Artifact event, got {other:?}"),
4672 }
4673 // The `Artifact` dual-write must never collide with / overwrite
4674 // the producing step's own `Final` name — `submit_output` never
4675 // materialized a `Final` here, so `"echo"` must stay unresolved.
4676 assert!(
4677 data_store.get_latest_by_name("echo").await.is_err(),
4678 "artifact write must not fabricate a record under the raw producer_agent name"
4679 );
4680 }
4681
4682 /// Invariant 1 (fail-open) for `Artifact`, mirroring
4683 /// `submit_output_final_skips_file_when_root_unresolved`'s Final-side
4684 /// coverage: no `OutputStore` wired at all — submit still succeeds.
4685 #[tokio::test]
4686 async fn submit_output_artifact_is_fail_open_when_no_output_store_configured() {
4687 let (engine, _op, task_id, worker_token) = seeded_task("echo").await;
4688
4689 let result = engine
4690 .submit_output(
4691 &worker_token,
4692 &task_id,
4693 1,
4694 OutputEvent::Artifact {
4695 name: "audit:echo".to_string(),
4696 content: ContentRef::Inline {
4697 value: serde_json::json!("finding"),
4698 },
4699 },
4700 )
4701 .await;
4702 assert!(
4703 result.is_ok(),
4704 "submit must succeed even with no OutputStore wired (fail-open, Invariant 1)"
4705 );
4706 }
4707
4708 /// `submit_worker_result_trusted` (the `/v1/worker/submit` short-handle
4709 /// path) triggers the exact same sink as `submit_output` — parity
4710 /// across both worker-submit entry points.
4711 #[tokio::test]
4712 async fn submit_worker_result_trusted_also_triggers_projection_sink() {
4713 let dir = tempfile::TempDir::new().unwrap();
4714 let (engine, _op, task_id, _worker_token) = seeded_task("planner").await;
4715 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4716 let data_store: Arc<dyn crate::store::output::OutputStore> =
4717 Arc::new(InMemoryOutputStore::new());
4718 engine.set_output_store(data_store.clone());
4719
4720 engine
4721 .submit_worker_result_trusted(
4722 &task_id,
4723 1,
4724 serde_json::json!("trusted-value"),
4725 SubmitOutcome::Pass,
4726 )
4727 .await
4728 .expect("submit_worker_result_trusted");
4729
4730 let expected_file = dir
4731 .path()
4732 .join("workspace/tasks")
4733 .join(task_id.as_str())
4734 .join("ctx/planner.md");
4735 assert!(expected_file.exists());
4736 let record = data_store
4737 .get_latest_by_name("planner")
4738 .await
4739 .expect("dual-written record");
4740 assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
4741 }
4742
4743 /// GH #23 subtask-2 (canonical sink): a declared `projection_name`
4744 /// (`AgentMeta.projection_name`, surfaced via `StepNaming`) redirects
4745 /// `submit_output`'s Final canonical sink — both the Data-plane
4746 /// dual-write name and the materialized file stem resolve to the
4747 /// canonical name, not the raw `producer_agent`.
4748 #[tokio::test]
4749 async fn submit_output_final_uses_canonical_name_when_step_naming_declares_one() {
4750 let dir = tempfile::TempDir::new().unwrap();
4751 let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
4752 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4753 seed_step_naming(&engine, &task_id, "reviewer", "verdict-final").await;
4754 let data_store: Arc<dyn crate::store::output::OutputStore> =
4755 Arc::new(InMemoryOutputStore::new());
4756 engine.set_output_store(data_store.clone());
4757
4758 engine
4759 .submit_output(
4760 &worker_token,
4761 &task_id,
4762 1,
4763 final_event(serde_json::json!({"verdict": "pass"}), true),
4764 )
4765 .await
4766 .expect("submit_output");
4767
4768 let record = data_store
4769 .get_latest_by_name("verdict-final")
4770 .await
4771 .expect("dual-written record under canonical name");
4772 assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
4773 assert!(
4774 data_store.get_latest_by_name("reviewer").await.is_err(),
4775 "raw producer_agent name must not be written once canonical resolves"
4776 );
4777
4778 let expected_file = dir
4779 .path()
4780 .join("workspace/tasks")
4781 .join(task_id.as_str())
4782 .join("ctx/verdict-final.md");
4783 assert!(
4784 expected_file.exists(),
4785 "materialized file stem must be canonical at {expected_file:?}"
4786 );
4787 }
4788
4789 /// GH #23 subtask-2: no `StepNaming` table snapshotted for this
4790 /// `task_id` (the pre-GH-#23 / no-`with_step_naming` path) is a
4791 /// defensive fail-open — the canonical sink falls back to the raw
4792 /// `producer_agent`, byte-identical to
4793 /// `submit_output_final_dual_writes_into_configured_output_store`
4794 /// above (which never calls `seed_step_naming`).
4795 #[tokio::test]
4796 async fn submit_output_final_falls_back_to_producer_agent_when_no_step_naming_table() {
4797 let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
4798 let data_store: Arc<dyn crate::store::output::OutputStore> =
4799 Arc::new(InMemoryOutputStore::new());
4800 engine.set_output_store(data_store.clone());
4801
4802 engine
4803 .submit_output(
4804 &worker_token,
4805 &task_id,
4806 1,
4807 final_event(serde_json::json!({"verdict": "pass"}), true),
4808 )
4809 .await
4810 .expect("submit_output");
4811
4812 let record = data_store
4813 .get_latest_by_name("reviewer")
4814 .await
4815 .expect("fail-open dual-write under raw producer_agent name");
4816 assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
4817 }
4818
4819 /// GH #23 subtask-2 (Layer 2): `OutputStore::get_latest_by_name_in_run`
4820 /// resolves the value `submit_output` dual-wrote for this exact
4821 /// `(task_id, attempt)` run, independent of `get_latest_by_name`'s
4822 /// cross-Run race (two Runs sharing a producer name never bleed into
4823 /// each other through the Run-scoped accessor).
4824 #[tokio::test]
4825 async fn submit_output_final_is_resolvable_via_run_scoped_lookup() {
4826 let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
4827 let data_store: Arc<dyn crate::store::output::OutputStore> =
4828 Arc::new(InMemoryOutputStore::new());
4829 engine.set_output_store(data_store.clone());
4830
4831 engine
4832 .submit_output(
4833 &worker_token,
4834 &task_id,
4835 1,
4836 final_event(serde_json::json!({"verdict": "pass"}), true),
4837 )
4838 .await
4839 .expect("submit_output");
4840
4841 let record = data_store
4842 .get_latest_by_name_in_run(task_id.as_str(), 1, "reviewer")
4843 .await
4844 .expect("run-scoped lookup resolves the dual-written record");
4845 assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
4846
4847 // A different attempt of the same task must not resolve — the
4848 // Run-scoped lookup does not fall back across attempts.
4849 assert!(
4850 data_store
4851 .get_latest_by_name_in_run(task_id.as_str(), 2, "reviewer")
4852 .await
4853 .is_err(),
4854 "a different attempt must not resolve the same-named record"
4855 );
4856 }
4857
4858 // ─── staged part file materialize ───
4859
4860 /// Staging a part with a resolved `work_dir` writes
4861 /// `<work_dir>/workspace/tasks/<task_id>/ctx/<name>` with the part's
4862 /// content RAW (no front matter / fenced wrapper).
4863 #[tokio::test]
4864 async fn stage_artifact_materializes_part_file_when_work_dir_resolved() {
4865 let dir = tempfile::TempDir::new().unwrap();
4866 let (engine, _op, task_id, _worker_token) = seeded_task("planner").await;
4867 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4868
4869 engine
4870 .stage_worker_artifact_trusted(
4871 &task_id,
4872 1,
4873 "plan.md".to_string(),
4874 serde_json::json!("# Plan\n\nstep one\n"),
4875 )
4876 .await
4877 .expect("stage artifact");
4878
4879 let expected_file = dir
4880 .path()
4881 .join("workspace/tasks")
4882 .join(task_id.as_str())
4883 .join("ctx/plan.md");
4884 assert!(
4885 expected_file.exists(),
4886 "materialized part file missing at {expected_file:?}"
4887 );
4888 let body = std::fs::read_to_string(expected_file).unwrap();
4889 // Raw — no YAML front matter / fenced-json wrapper.
4890 assert_eq!(body, "# Plan\n\nstep one\n");
4891 }
4892
4893 /// No resolvable root + `Warn` — staging still
4894 /// succeeds (fail-open), and no part file is written.
4895 #[tokio::test]
4896 async fn stage_artifact_check_policy_warn_skips_part_file_when_root_unresolved() {
4897 let dir = tempfile::TempDir::new().unwrap();
4898 let (engine, _op, task_id, _worker_token) =
4899 seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Warn).await;
4900 // No seed_agent_context — root unresolved.
4901
4902 let result = engine
4903 .stage_worker_artifact_trusted(
4904 &task_id,
4905 1,
4906 "plan.md".to_string(),
4907 serde_json::json!("x"),
4908 )
4909 .await;
4910 assert!(
4911 result.is_ok(),
4912 "Warn mode preserves fail-open: stage must succeed when root unresolved"
4913 );
4914 assert!(
4915 !dir.path().join("workspace").exists(),
4916 "no part file may be materialized when root is unresolved"
4917 );
4918 }
4919
4920 /// No resolvable root + `Strict` — staging surfaces
4921 /// the fail-open condition as an [`EngineError::CheckPolicyStrict`],
4922 /// its message identifying the "part file materialize" call site.
4923 #[tokio::test]
4924 async fn stage_artifact_check_policy_strict_surfaces_error_when_root_unresolved() {
4925 let (engine, _op, task_id, _worker_token) =
4926 seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Strict).await;
4927
4928 let err = engine
4929 .stage_worker_artifact_trusted(
4930 &task_id,
4931 1,
4932 "plan.md".to_string(),
4933 serde_json::json!("x"),
4934 )
4935 .await
4936 .expect_err("Strict mode must return an error when root unresolved");
4937 match err {
4938 EngineError::CheckPolicyStrict { context, message } => {
4939 assert!(
4940 context.contains("part file materialize"),
4941 "context must identify the call site: {context}"
4942 );
4943 assert!(
4944 message.contains("part file materialize"),
4945 "message must identify the part-file sink: {message}"
4946 );
4947 assert!(
4948 message.contains("no work_dir/project_root resolved"),
4949 "message must preserve the warn-log literal: {message}"
4950 );
4951 }
4952 other => panic!(
4953 "expected EngineError::CheckPolicyStrict, got a different variant: {other:?}"
4954 ),
4955 }
4956 }
4957
4958 /// A path-traversal `name` (`../evil.md`) with a
4959 /// resolved root — the name guard fails the write, but fail-open keeps
4960 /// the stage succeeding, and nothing is written outside the ctx dir.
4961 #[tokio::test]
4962 async fn stage_artifact_traversal_name_is_fail_open_and_writes_nothing() {
4963 let dir = tempfile::TempDir::new().unwrap();
4964 let (engine, _op, task_id, _worker_token) = seeded_task("planner").await;
4965 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4966
4967 let result = engine
4968 .stage_worker_artifact_trusted(
4969 &task_id,
4970 1,
4971 "../evil.md".to_string(),
4972 serde_json::json!("pwned"),
4973 )
4974 .await;
4975 assert!(
4976 result.is_ok(),
4977 "default (Warn) policy is fail-open even on a rejected part name"
4978 );
4979 // The escaped target (ctx dir's parent) must not have been written.
4980 let escaped = dir
4981 .path()
4982 .join("workspace/tasks")
4983 .join(task_id.as_str())
4984 .join("evil.md");
4985 assert!(
4986 !escaped.exists(),
4987 "a traversal name must never write outside the ctx dir: {escaped:?}"
4988 );
4989 }
4990}
4991
4992/// GH #36 ST1: named multi-part worker output. Covers (a) the pure
4993/// `fold_final_and_parts` assembly `dispatch_attempt_with`'s Final-pull
4994/// delegates to, (b) `stage_worker_artifact_trusted`'s per-attempt
4995/// isolation on `EngineState.output_store` / `.worker_artifact_names` (the
4996/// same `HashMap<(StepId, u32), _>` key shape `submit_worker_result_trusted`
4997/// uses — a fresh attempt is a fresh key, so nothing to explicitly "clean
4998/// up"), and (c) the allowlist behavior that keeps a non-opt-in `Artifact`
4999/// producer (e.g. `AfterRunAuditMiddleware`) from being folded in.
5000#[cfg(test)]
5001mod named_multi_part_worker_output_tests {
5002 use super::*;
5003 use crate::worker::output::{ContentRef, OutputEvent};
5004
5005 fn artifact(name: &str, value: Value) -> OutputEvent {
5006 OutputEvent::Artifact {
5007 name: name.to_string(),
5008 content: ContentRef::Inline { value },
5009 }
5010 }
5011
5012 fn final_ev(value: Value, ok: bool) -> OutputEvent {
5013 OutputEvent::Final {
5014 content: ContentRef::Inline { value },
5015 ok,
5016 }
5017 }
5018
5019 fn names(list: &[&str]) -> Vec<String> {
5020 list.iter().map(|s| s.to_string()).collect()
5021 }
5022
5023 /// Two staged parts (both in `staged_names`) + a `Final` fold into
5024 /// `{"out", "parts"}`, each value carried through verbatim.
5025 #[test]
5026 fn fold_final_and_parts_assembles_out_and_parts_shape() {
5027 let tail = vec![
5028 artifact("summary", serde_json::json!("the summary")),
5029 artifact("diff", serde_json::json!({"lines": 3})),
5030 final_ev(serde_json::json!("final text"), true),
5031 ];
5032 let staged = names(&["summary", "diff"]);
5033 let (value, ok) = fold_final_and_parts(&tail, &staged).expect("Final present");
5034 assert!(ok);
5035 assert_eq!(
5036 value,
5037 serde_json::json!({
5038 "out": "final text",
5039 "parts": {
5040 "summary": "the summary",
5041 "diff": {"lines": 3},
5042 }
5043 })
5044 );
5045 }
5046
5047 /// Zero staged parts: the value is exactly the plain `Final` value — no
5048 /// `{"out", "parts"}` wrapping. This is the back-compat guarantee (GH
5049 /// #36 must not change the shape for a worker that never POSTs to
5050 /// `/v1/worker/artifact`).
5051 #[test]
5052 fn fold_final_and_parts_with_no_parts_returns_plain_final_value() {
5053 let tail = vec![final_ev(serde_json::json!("plain value"), true)];
5054 let (value, ok) = fold_final_and_parts(&tail, &[]).expect("Final present");
5055 assert!(ok);
5056 assert_eq!(value, serde_json::json!("plain value"));
5057 }
5058
5059 /// The same staged part `name` appearing twice in one attempt: the
5060 /// LATER (tail-order) value wins — `parts` is a `Map`, not an
5061 /// accumulating list.
5062 #[test]
5063 fn fold_final_and_parts_same_name_twice_last_write_wins() {
5064 let tail = vec![
5065 artifact("a", serde_json::json!("first")),
5066 artifact("a", serde_json::json!("second")),
5067 final_ev(serde_json::json!("f"), true),
5068 ];
5069 let staged = names(&["a"]);
5070 let (value, _ok) = fold_final_and_parts(&tail, &staged).expect("Final present");
5071 assert_eq!(
5072 value,
5073 serde_json::json!({"out": "f", "parts": {"a": "second"}})
5074 );
5075 }
5076
5077 /// No `Final` anywhere in the tail (only staged parts, e.g. the worker
5078 /// crashed before submitting) — `None`, the caller's pre-existing "no
5079 /// Final in output_tail" error path.
5080 #[test]
5081 fn fold_final_and_parts_returns_none_when_no_final_present() {
5082 let tail = vec![artifact("a", serde_json::json!("v"))];
5083 let staged = names(&["a"]);
5084 assert!(fold_final_and_parts(&tail, &staged).is_none());
5085 }
5086
5087 /// An `Artifact` on the tail whose name is NOT in `staged_names` (e.g.
5088 /// `AfterRunAuditMiddleware`'s `"audit:<step_ref>"` sidecar finding on
5089 /// an audited step's own tail) must NOT be folded into `"parts"` — the
5090 /// value stays the plain `Final` value, exactly the pre-GH-#36
5091 /// behavior for every producer that isn't the worker's own
5092 /// `/v1/worker/artifact` staging. This is the regression this fold was
5093 /// almost shipped without (see `dispatch_attempt_with`'s doc).
5094 #[test]
5095 fn fold_final_and_parts_ignores_artifacts_outside_the_staged_allowlist() {
5096 let tail = vec![
5097 final_ev(serde_json::json!({"echoed": "hi"}), true),
5098 artifact("audit:echo", serde_json::json!({"finding": "clean"})),
5099 ];
5100 // `staged_names` empty: the worker itself never staged anything —
5101 // the audit sidecar Artifact must be ignored.
5102 let (value, ok) = fold_final_and_parts(&tail, &[]).expect("Final present");
5103 assert!(ok);
5104 assert_eq!(value, serde_json::json!({"echoed": "hi"}));
5105 }
5106
5107 /// Mixed tail: one staged (allowlisted) part and one non-staged
5108 /// (audit-style) `Artifact` — only the staged one is folded in.
5109 #[test]
5110 fn fold_final_and_parts_folds_only_the_staged_subset_of_a_mixed_tail() {
5111 let tail = vec![
5112 artifact("summary", serde_json::json!("s")),
5113 artifact("audit:echo", serde_json::json!({"finding": "clean"})),
5114 final_ev(serde_json::json!("f"), true),
5115 ];
5116 let staged = names(&["summary"]);
5117 let (value, _ok) = fold_final_and_parts(&tail, &staged).expect("Final present");
5118 assert_eq!(
5119 value,
5120 serde_json::json!({"out": "f", "parts": {"summary": "s"}})
5121 );
5122 }
5123
5124 /// `stage_worker_artifact_trusted` writes onto the `(task_id, attempt)`
5125 /// key exactly like `submit_worker_result_trusted` does — a part staged
5126 /// under attempt N is invisible to an `output_tail` / allowlist read of
5127 /// attempt N+1 (a fresh attempt starts empty; nothing carries over).
5128 #[tokio::test]
5129 async fn stage_worker_artifact_trusted_is_isolated_per_attempt() {
5130 let engine = Engine::new(EngineCfg::default());
5131 let task_id = StepId::new();
5132
5133 engine
5134 .stage_worker_artifact_trusted(&task_id, 1, "a".to_string(), serde_json::json!("v1"))
5135 .await
5136 .expect("stage attempt 1");
5137
5138 let attempt_1_tail = engine.output_tail(&task_id, 1).await;
5139 assert_eq!(attempt_1_tail.len(), 1);
5140 assert!(matches!(
5141 &attempt_1_tail[0],
5142 OutputEvent::Artifact { name, .. } if name == "a"
5143 ));
5144 assert_eq!(
5145 engine.worker_artifact_names_for(&task_id, 1).await,
5146 vec!["a".to_string()]
5147 );
5148
5149 let attempt_2_tail = engine.output_tail(&task_id, 2).await;
5150 assert!(
5151 attempt_2_tail.is_empty(),
5152 "attempt 2 must not see attempt 1's staged part"
5153 );
5154 assert!(
5155 engine
5156 .worker_artifact_names_for(&task_id, 2)
5157 .await
5158 .is_empty(),
5159 "attempt 2's allowlist must not see attempt 1's staged name"
5160 );
5161 }
5162}
5163
5164// ─── GH #50 (Subtask 2): `Engine::register_verdict_contracts` /
5165// `Engine::verdict_contract_for_task` ────────────────────────────────────
5166#[cfg(test)]
5167mod verdict_contract_registry_tests {
5168 use super::*;
5169
5170 async fn seeded_engine(agent: &str) -> (Engine, StepId) {
5171 let engine = Engine::new(EngineCfg::default());
5172 let op_token = engine
5173 .attach("ut-op", Role::Operator, Duration::from_secs(30))
5174 .await
5175 .expect("attach");
5176 let task_id = engine
5177 .start_task(
5178 &op_token,
5179 TaskSpec {
5180 agent: agent.to_string(),
5181 initial_directive: serde_json::json!("x"),
5182 step_ctx: None,
5183 check_policy: None,
5184 },
5185 )
5186 .await
5187 .expect("start_task");
5188 (engine, task_id)
5189 }
5190
5191 /// An agent with no registered contract at all → `None` (the opt-in
5192 /// default; every pre-GH-#50 `Engine`).
5193 #[tokio::test]
5194 async fn returns_none_when_no_contract_registered_for_the_agent() {
5195 let (engine, task_id) = seeded_engine("gate").await;
5196 assert_eq!(engine.verdict_contract_for_task(&task_id).await, None);
5197 }
5198
5199 /// A registered contract for the running task's agent is returned
5200 /// verbatim.
5201 #[tokio::test]
5202 async fn returns_the_registered_contract_for_the_running_agent() {
5203 let (engine, task_id) = seeded_engine("gate").await;
5204 let contract = mlua_swarm_schema::VerdictContract {
5205 channel: mlua_swarm_schema::VerdictChannel::Body,
5206 values: vec!["PASS".to_string(), "BLOCKED".to_string()],
5207 };
5208 engine.register_verdict_contracts(HashMap::from([("gate".to_string(), contract.clone())]));
5209 assert_eq!(
5210 engine.verdict_contract_for_task(&task_id).await,
5211 Some(contract)
5212 );
5213 }
5214
5215 /// A registered contract for a DIFFERENT agent name never leaks onto
5216 /// an unrelated task.
5217 #[tokio::test]
5218 async fn does_not_leak_a_contract_registered_for_a_different_agent() {
5219 let (engine, task_id) = seeded_engine("gate").await;
5220 engine.register_verdict_contracts(HashMap::from([(
5221 "other-agent".to_string(),
5222 mlua_swarm_schema::VerdictContract {
5223 channel: mlua_swarm_schema::VerdictChannel::Body,
5224 values: vec!["PASS".to_string()],
5225 },
5226 )]));
5227 assert_eq!(engine.verdict_contract_for_task(&task_id).await, None);
5228 }
5229
5230 /// An unknown `task_id` → `None`, not a panic / error.
5231 #[tokio::test]
5232 async fn returns_none_for_an_unknown_task_id() {
5233 let engine = Engine::new(EngineCfg::default());
5234 let unknown = StepId::new();
5235 assert_eq!(engine.verdict_contract_for_task(&unknown).await, None);
5236 }
5237
5238 /// `register_verdict_contracts` is additive (`HashMap::extend`): a
5239 /// second call registering a DIFFERENT agent does not clobber the
5240 /// first call's entry.
5241 #[tokio::test]
5242 async fn register_verdict_contracts_is_additive_across_calls() {
5243 let (engine, task_id) = seeded_engine("gate").await;
5244 let contract = mlua_swarm_schema::VerdictContract {
5245 channel: mlua_swarm_schema::VerdictChannel::Part,
5246 values: vec!["ALLOW".to_string()],
5247 };
5248 engine.register_verdict_contracts(HashMap::from([("gate".to_string(), contract.clone())]));
5249 engine.register_verdict_contracts(HashMap::from([(
5250 "unrelated-agent".to_string(),
5251 mlua_swarm_schema::VerdictContract {
5252 channel: mlua_swarm_schema::VerdictChannel::Body,
5253 values: vec!["X".to_string()],
5254 },
5255 )]));
5256 assert_eq!(
5257 engine.verdict_contract_for_task(&task_id).await,
5258 Some(contract)
5259 );
5260 }
5261}
5262
5263// ─── GH #51: completion-time verdict-contract enforcement — the shared
5264// `Engine::verdict_contract_completion_check` choke point embedded inside
5265// `submit_worker_result_trusted` / `submit_output`, exercised here at the
5266// `submit_output` level (the WS Operator fallback route's own unit-test
5267// coverage — see `crates/mlua-swarm-server/tests/verdict_contract.rs` for
5268// the HTTP-round-trip coverage of the other 2 routes) ───────────────────
5269#[cfg(test)]
5270mod verdict_contract_completion_tests {
5271 use super::*;
5272
5273 /// Seeds a `Pending` task bound to `agent` and mints a bound
5274 /// `Role::Worker` token for it — the same mint-and-register pattern
5275 /// `initial_directive_value_passthrough_tests::mint_worker_token`
5276 /// uses (duplicated here: that helper is private to its own sibling
5277 /// `#[cfg(test)]` module, not reachable via `super::*` from this one).
5278 async fn seeded_task_with_worker_token(agent: &str) -> (Engine, CapToken, StepId) {
5279 let engine = Engine::new(EngineCfg::default());
5280 let op_token = engine
5281 .attach("ut-op", Role::Operator, Duration::from_secs(30))
5282 .await
5283 .expect("attach");
5284 let task_id = engine
5285 .start_task(
5286 &op_token,
5287 TaskSpec {
5288 agent: agent.to_string(),
5289 initial_directive: serde_json::json!("x"),
5290 step_ctx: None,
5291 check_policy: None,
5292 },
5293 )
5294 .await
5295 .expect("start_task");
5296 let worker_token = engine.signer().session(
5297 format!("worker-of-{task_id}"),
5298 Role::Worker,
5299 vec!["*".into()],
5300 Duration::from_secs(600),
5301 );
5302 let fp = worker_token.fingerprint();
5303 let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
5304 engine
5305 .with_state("test.mint_worker", move |s| {
5306 s.tokens.insert(fp, record);
5307 })
5308 .await
5309 .expect("mint worker token");
5310 (engine, worker_token, task_id)
5311 }
5312
5313 fn body_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
5314 mlua_swarm_schema::VerdictContract {
5315 channel: mlua_swarm_schema::VerdictChannel::Body,
5316 values: values.iter().map(|v| v.to_string()).collect(),
5317 }
5318 }
5319
5320 fn part_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
5321 mlua_swarm_schema::VerdictContract {
5322 channel: mlua_swarm_schema::VerdictChannel::Part,
5323 values: values.iter().map(|v| v.to_string()).collect(),
5324 }
5325 }
5326
5327 fn final_event(value: Value, ok: bool) -> crate::worker::output::OutputEvent {
5328 crate::worker::output::OutputEvent::Final {
5329 content: crate::worker::output::ContentRef::Inline { value },
5330 ok,
5331 }
5332 }
5333
5334 /// Route 3 (WS Operator fallback, `submit_output` level) — a
5335 /// `channel: "part"` contract's attempt completes via a plain
5336 /// `Final` without ever staging a `"verdict"` artifact: rejected
5337 /// with `EngineError::VerdictPartMissing`, and nothing lands on
5338 /// `output_tail` — the rejected value never reaches the flow ctx.
5339 #[tokio::test]
5340 async fn submit_output_rejects_missing_verdict_part() {
5341 let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
5342 engine.register_verdict_contracts(HashMap::from([(
5343 "gate".to_string(),
5344 part_contract(&["PASS", "BLOCKED"]),
5345 )]));
5346
5347 let err = engine
5348 .submit_output(
5349 &token,
5350 &task_id,
5351 1,
5352 final_event(serde_json::json!("anything"), true),
5353 )
5354 .await
5355 .expect_err("missing staged verdict part must be rejected");
5356 assert!(
5357 matches!(err, EngineError::VerdictPartMissing { .. }),
5358 "unexpected error variant: {err:?}"
5359 );
5360
5361 let tail = engine.output_tail(&task_id, 1).await;
5362 assert!(
5363 !tail
5364 .iter()
5365 .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
5366 "a rejected completion must not write a Final onto output_tail"
5367 );
5368 }
5369
5370 /// Route 3 — a `channel: "part"` contract completes normally when the
5371 /// worker DID stage a matching `"verdict"` artifact first (defense in
5372 /// depth: presence AND membership both hold).
5373 #[tokio::test]
5374 async fn submit_output_accepts_when_verdict_part_is_staged_and_a_member() {
5375 let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
5376 engine.register_verdict_contracts(HashMap::from([(
5377 "gate".to_string(),
5378 part_contract(&["PASS", "BLOCKED"]),
5379 )]));
5380 engine
5381 .stage_worker_artifact_trusted(
5382 &task_id,
5383 1,
5384 "verdict".to_string(),
5385 serde_json::json!("PASS"),
5386 )
5387 .await
5388 .expect("stage verdict part");
5389
5390 engine
5391 .submit_output(
5392 &token,
5393 &task_id,
5394 1,
5395 final_event(serde_json::json!("full report"), true),
5396 )
5397 .await
5398 .expect("staged + member verdict part must be accepted");
5399
5400 let tail = engine.output_tail(&task_id, 1).await;
5401 assert!(
5402 tail.iter()
5403 .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
5404 "an accepted completion must write its Final onto output_tail"
5405 );
5406 }
5407
5408 /// Route 3 — a `channel: "body"` contract's completing value is NOT a
5409 /// member of `values`: rejected with
5410 /// `EngineError::VerdictValueRejected`, no `Final` written.
5411 #[tokio::test]
5412 async fn submit_output_rejects_body_value_outside_contract() {
5413 let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
5414 engine.register_verdict_contracts(HashMap::from([(
5415 "gate".to_string(),
5416 body_contract(&["PASS", "BLOCKED"]),
5417 )]));
5418
5419 let err = engine
5420 .submit_output(
5421 &token,
5422 &task_id,
5423 1,
5424 final_event(serde_json::json!("UNKNOWN"), true),
5425 )
5426 .await
5427 .expect_err("out-of-contract body value must be rejected");
5428 match err {
5429 EngineError::VerdictValueRejected { value, allowed } => {
5430 assert_eq!(value, "UNKNOWN");
5431 assert_eq!(allowed, vec!["PASS".to_string(), "BLOCKED".to_string()]);
5432 }
5433 other => panic!("unexpected error variant: {other:?}"),
5434 }
5435
5436 let tail = engine.output_tail(&task_id, 1).await;
5437 assert!(
5438 !tail
5439 .iter()
5440 .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
5441 "a rejected completion must not write a Final onto output_tail"
5442 );
5443 }
5444
5445 /// `ok=false` bypasses the completion-time check entirely, regardless
5446 /// of channel or membership — the exemption acceptance criterion,
5447 /// exercised at the `submit_output` choke point.
5448 #[tokio::test]
5449 async fn submit_output_ok_false_bypasses_the_check() {
5450 let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
5451 engine.register_verdict_contracts(HashMap::from([(
5452 "gate".to_string(),
5453 body_contract(&["PASS", "BLOCKED"]),
5454 )]));
5455
5456 engine
5457 .submit_output(
5458 &token,
5459 &task_id,
5460 1,
5461 final_event(serde_json::json!("UNKNOWN"), false),
5462 )
5463 .await
5464 .expect("ok=false must bypass the verdict contract check entirely");
5465
5466 let tail = engine.output_tail(&task_id, 1).await;
5467 assert!(
5468 tail.iter()
5469 .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
5470 "an ok=false completion is exempt, not rejected — its Final must still land"
5471 );
5472 }
5473
5474 /// `staged_verdict_value_for` mirrors `fold_final_and_parts`'s
5475 /// last-write-wins semantics: staging `"verdict"` twice within the
5476 /// same attempt returns the LAST value, not the first.
5477 #[tokio::test]
5478 async fn staged_verdict_value_for_is_last_write_wins() {
5479 let (engine, _token, task_id) = seeded_task_with_worker_token("gate").await;
5480 engine
5481 .stage_worker_artifact_trusted(
5482 &task_id,
5483 1,
5484 "verdict".to_string(),
5485 serde_json::json!("PASS"),
5486 )
5487 .await
5488 .expect("stage first verdict part");
5489 engine
5490 .stage_worker_artifact_trusted(
5491 &task_id,
5492 1,
5493 "verdict".to_string(),
5494 serde_json::json!("BLOCKED"),
5495 )
5496 .await
5497 .expect("stage second verdict part");
5498
5499 assert_eq!(
5500 engine.staged_verdict_value_for(&task_id, 1).await,
5501 Some("BLOCKED".to_string())
5502 );
5503 }
5504
5505 /// `staged_verdict_value_for` ignores artifacts staged under any name
5506 /// OTHER than the literal `"verdict"` — mirrors `channel: "part"`
5507 /// contracts only ever addressing that one part.
5508 #[tokio::test]
5509 async fn staged_verdict_value_for_ignores_other_artifact_names() {
5510 let (engine, _token, task_id) = seeded_task_with_worker_token("gate").await;
5511 engine
5512 .stage_worker_artifact_trusted(
5513 &task_id,
5514 1,
5515 "notes".to_string(),
5516 serde_json::json!("irrelevant"),
5517 )
5518 .await
5519 .expect("stage unrelated part");
5520
5521 assert_eq!(engine.staged_verdict_value_for(&task_id, 1).await, None);
5522 }
5523
5524 /// `staged_verdict_value_for` → `None` when nothing was ever staged —
5525 /// the normal case the completion check turns into
5526 /// `EngineError::VerdictPartMissing`.
5527 #[tokio::test]
5528 async fn staged_verdict_value_for_returns_none_when_nothing_staged() {
5529 let (engine, _token, task_id) = seeded_task_with_worker_token("gate").await;
5530 assert_eq!(engine.staged_verdict_value_for(&task_id, 1).await, None);
5531 }
5532}
5533
5534// ─── GH #76 Skip tier: DispatchOutcome::Skip tier + SubmitOutcome API ────────────
5535#[cfg(test)]
5536mod skip_tier_tests {
5537 use super::*;
5538 use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerFactory};
5539 use crate::blueprint::EngineDispatcher;
5540 use crate::core::state::{
5541 is_skip_marker, unwrap_skip_marker, wrap_skip_marker, SubmitOutcome, SKIP_MARKER_KEY,
5542 };
5543 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
5544 use crate::types::{RunId, TaskId};
5545 use crate::worker::adapter::WorkerResult;
5546 use mlua_flow_ir::AsyncDispatcher;
5547 use mlua_swarm_schema::{AgentDef, AgentKind};
5548 use serde_json::json;
5549
5550 /// `DispatchOutcome::Skip(v)` roundtrips through serde JSON without
5551 /// loss — the enum is serialized with the default externally-tagged
5552 /// form (same as `Pass`/`Blocked`), so no `#[serde(...)]` tuning is
5553 /// needed for the new variant.
5554 #[test]
5555 fn dispatch_outcome_skip_variant_serializes_roundtrip() {
5556 let outcome = DispatchOutcome::Skip(json!({ "verdict": "SKIP", "reason": "n/a" }));
5557 let serialized = serde_json::to_string(&outcome).expect("serialize");
5558 let round: DispatchOutcome = serde_json::from_str(&serialized).expect("deserialize");
5559 match round {
5560 DispatchOutcome::Skip(v) => {
5561 assert_eq!(v, json!({ "verdict": "SKIP", "reason": "n/a" }));
5562 }
5563 other => panic!("expected Skip after roundtrip, got {other:?}"),
5564 }
5565 }
5566
5567 /// The `is_skip_marker` / `unwrap_skip_marker` / `wrap_skip_marker`
5568 /// helper triangle round-trips consistently and rejects plain
5569 /// payloads. Pinning the reserved-key contract in a unit test guards
5570 /// against a future edit accidentally renaming the sentinel key
5571 /// (which would silently break every downstream reader).
5572 #[test]
5573 fn skip_marker_helpers_wrap_detect_and_unwrap() {
5574 assert!(!is_skip_marker(&json!("plain string")));
5575 assert!(!is_skip_marker(&json!({ "verdict": "PASS" })));
5576 assert!(!is_skip_marker(&json!(null)));
5577
5578 let inner = json!({ "reason": "not applicable" });
5579 let wrapped = wrap_skip_marker(inner.clone());
5580 assert!(is_skip_marker(&wrapped));
5581 assert_eq!(wrapped[SKIP_MARKER_KEY], json!(true));
5582 assert_eq!(unwrap_skip_marker(&wrapped), Some(inner));
5583
5584 // A malformed sentinel (marker key present but `value` absent) is
5585 // still a Skip signal, defaulting the carried payload to Null so
5586 // downstream match arms never observe `None` on a marker match.
5587 let malformed = json!({ SKIP_MARKER_KEY: true });
5588 assert!(is_skip_marker(&malformed));
5589 assert_eq!(unwrap_skip_marker(&malformed), Some(Value::Null));
5590
5591 // Plain payloads → `unwrap_skip_marker` returns `None` (the
5592 // caller falls back to the ordinary Pass/Blocked path).
5593 assert_eq!(unwrap_skip_marker(&json!("plain")), None);
5594 }
5595
5596 /// The `SubmitOutcome::Skip` mapping wraps the payload in the
5597 /// skip-marker sentinel AND records `Final.ok = true` — matching the
5598 /// invariant in the outcome mapping table in
5599 /// `submit_worker_result_trusted`'s doc. This is the wire shape
5600 /// `dispatch_attempt_with*` reads back to route into
5601 /// `DispatchOutcome::Skip`.
5602 #[tokio::test]
5603 async fn submit_worker_result_trusted_skip_outcome_records_final_ok_true_with_sentinel() {
5604 use crate::worker::output::OutputEvent;
5605 let engine = Engine::new(EngineCfg::default());
5606 let op_token = engine
5607 .attach("ut-op", Role::Operator, Duration::from_secs(30))
5608 .await
5609 .expect("attach");
5610 let task_id = engine
5611 .start_task(
5612 &op_token,
5613 TaskSpec {
5614 agent: "analyst".into(),
5615 initial_directive: json!("go"),
5616 step_ctx: None,
5617 check_policy: None,
5618 },
5619 )
5620 .await
5621 .expect("start_task");
5622
5623 let inner_verdict = json!({ "verdict": "SKIP", "reason": "migration=no" });
5624 engine
5625 .submit_worker_result_trusted(&task_id, 1, inner_verdict.clone(), SubmitOutcome::Skip)
5626 .await
5627 .expect("submit with Skip outcome");
5628
5629 let tail = engine.output_tail(&task_id, 1).await;
5630 let final_ev = tail
5631 .iter()
5632 .rev()
5633 .find_map(|ev| match ev {
5634 OutputEvent::Final { content, ok } => Some((content.clone(), *ok)),
5635 _ => None,
5636 })
5637 .expect("Final present after Skip submit");
5638 assert!(
5639 final_ev.1,
5640 "Skip records Final.ok = true (flow-continuation)"
5641 );
5642 let stored_value = super::content_ref_to_value(final_ev.0);
5643 assert!(
5644 is_skip_marker(&stored_value),
5645 "Skip wraps the payload in the sentinel: got {stored_value}"
5646 );
5647 assert_eq!(unwrap_skip_marker(&stored_value), Some(inner_verdict));
5648 }
5649
5650 /// The new `SubmitOutcome::Pass` / `SubmitOutcome::Blocked` arms
5651 /// preserve byte-for-byte the pre-#76 wire shape (Final.ok mirrors
5652 /// the tier; the value is not wrapped). Regression against a future
5653 /// edit that accidentally routes Pass/Blocked through the Skip
5654 /// wrapper.
5655 #[tokio::test]
5656 async fn submit_worker_result_trusted_pass_and_blocked_wire_unchanged() {
5657 use crate::worker::output::OutputEvent;
5658 let engine = Engine::new(EngineCfg::default());
5659 let op_token = engine
5660 .attach("ut-op", Role::Operator, Duration::from_secs(30))
5661 .await
5662 .expect("attach");
5663
5664 // Pass path.
5665 let pass_task = engine
5666 .start_task(
5667 &op_token,
5668 TaskSpec {
5669 agent: "worker".into(),
5670 initial_directive: json!("go"),
5671 step_ctx: None,
5672 check_policy: None,
5673 },
5674 )
5675 .await
5676 .expect("start_task pass");
5677 engine
5678 .submit_worker_result_trusted(&pass_task, 1, json!("pass-value"), SubmitOutcome::Pass)
5679 .await
5680 .expect("submit Pass");
5681 let pass_tail = engine.output_tail(&pass_task, 1).await;
5682 let (pass_content, pass_ok) = pass_tail
5683 .iter()
5684 .rev()
5685 .find_map(|ev| match ev {
5686 OutputEvent::Final { content, ok } => Some((content.clone(), *ok)),
5687 _ => None,
5688 })
5689 .expect("Final present");
5690 assert!(pass_ok);
5691 assert_eq!(
5692 super::content_ref_to_value(pass_content),
5693 json!("pass-value"),
5694 "Pass value must not be wrapped"
5695 );
5696
5697 // Blocked path.
5698 let blocked_task = engine
5699 .start_task(
5700 &op_token,
5701 TaskSpec {
5702 agent: "worker".into(),
5703 initial_directive: json!("go"),
5704 step_ctx: None,
5705 check_policy: None,
5706 },
5707 )
5708 .await
5709 .expect("start_task blocked");
5710 engine
5711 .submit_worker_result_trusted(
5712 &blocked_task,
5713 1,
5714 json!("blocked-value"),
5715 SubmitOutcome::Blocked,
5716 )
5717 .await
5718 .expect("submit Blocked");
5719 let blocked_tail = engine.output_tail(&blocked_task, 1).await;
5720 let (blocked_content, blocked_ok) = blocked_tail
5721 .iter()
5722 .rev()
5723 .find_map(|ev| match ev {
5724 OutputEvent::Final { content, ok } => Some((content.clone(), *ok)),
5725 _ => None,
5726 })
5727 .expect("Final present");
5728 assert!(!blocked_ok);
5729 assert_eq!(
5730 super::content_ref_to_value(blocked_content),
5731 json!("blocked-value"),
5732 "Blocked value must not be wrapped"
5733 );
5734 }
5735
5736 /// End-to-end (engine layer): a worker that returns a skip-marker
5737 /// sentinel value via `WorkerResult { value: wrap_skip_marker(inner),
5738 /// ok: true }` — which is what a Skip-aware caller of
5739 /// `submit_worker_result_trusted(..., SubmitOutcome::Skip)` places on
5740 /// the wire — is folded by `dispatch_attempt_with_run_ctx` into
5741 /// `DispatchOutcome::Skip(inner)`. Proves the sentinel → outcome
5742 /// routing that the flow-ir binding boundary depends on.
5743 #[tokio::test]
5744 async fn dispatcher_folds_skip_sentinel_into_skip_outcome() {
5745 let inner_verdict = json!({ "verdict": "SKIP", "reason": "not applicable" });
5746 let inner_for_worker = inner_verdict.clone();
5747 let factory = RustFnInProcessSpawnerFactory::new().register_fn("analyst", move |_inv| {
5748 let value = wrap_skip_marker(inner_for_worker.clone());
5749 async move {
5750 Ok(WorkerResult {
5751 value,
5752 ok: true,
5753 stats: None,
5754 })
5755 }
5756 });
5757 let def = AgentDef {
5758 name: "analyst".into(),
5759 kind: AgentKind::RustFn,
5760 spec: json!({ "fn_id": "analyst" }),
5761 profile: None,
5762 meta: None,
5763 runner: None,
5764 runner_ref: None,
5765 verdict: None,
5766 };
5767 let spawner = factory.build(&def, None).expect("build");
5768
5769 let engine = Engine::new(EngineCfg::default());
5770 let op_token = engine
5771 .attach("ut-op", Role::Operator, Duration::from_secs(30))
5772 .await
5773 .expect("attach");
5774 let task_id = engine
5775 .start_task(
5776 &op_token,
5777 TaskSpec {
5778 agent: "analyst".into(),
5779 initial_directive: json!("go"),
5780 step_ctx: None,
5781 check_policy: None,
5782 },
5783 )
5784 .await
5785 .expect("start_task");
5786
5787 let outcome = engine
5788 .dispatch_attempt_with_run_ctx(&op_token, &task_id, &spawner, None)
5789 .await
5790 .expect("dispatch ok");
5791
5792 match outcome {
5793 DispatchOutcome::Skip(v) => {
5794 assert_eq!(v, inner_verdict, "Skip carries the unwrapped inner verdict");
5795 }
5796 other => panic!("expected DispatchOutcome::Skip, got {other:?}"),
5797 }
5798 }
5799
5800 /// `EngineDispatcher::dispatch` (the `AsyncDispatcher` impl flow-ir
5801 /// invokes) maps `DispatchOutcome::Skip(v)` to `Ok(wrap_skip_marker(v))`
5802 /// — a successful return whose Value carries the sentinel across the
5803 /// flow-ir boundary. Pinning this mapping in a test guards the arm
5804 /// order (a wildcard `Ok(other) =>` arm accidentally placed BEFORE the
5805 /// Skip arm would route Skip to `EvalError::DispatcherError` and
5806 /// abort the flow — the exact failure mode this tier prevents).
5807 #[tokio::test]
5808 async fn engine_dispatcher_maps_skip_outcome_to_ok_sentinel_value() {
5809 let inner_verdict = json!({ "verdict": "SKIP", "reason": "not applicable" });
5810 let inner_for_worker = inner_verdict.clone();
5811 let factory = RustFnInProcessSpawnerFactory::new().register_fn("analyst", move |_inv| {
5812 let value = wrap_skip_marker(inner_for_worker.clone());
5813 async move {
5814 Ok(WorkerResult {
5815 value,
5816 ok: true,
5817 stats: None,
5818 })
5819 }
5820 });
5821 let def = AgentDef {
5822 name: "analyst".into(),
5823 kind: AgentKind::RustFn,
5824 spec: json!({ "fn_id": "analyst" }),
5825 profile: None,
5826 meta: None,
5827 runner: None,
5828 runner_ref: None,
5829 verdict: None,
5830 };
5831 let spawner = factory.build(&def, None).expect("build");
5832
5833 let engine = Engine::new(EngineCfg::default());
5834 let op_token = engine
5835 .attach("ut-op", Role::Operator, Duration::from_secs(30))
5836 .await
5837 .expect("attach");
5838 let dispatcher = EngineDispatcher::with_spawner(engine.clone(), op_token, spawner);
5839
5840 let out = dispatcher
5841 .dispatch("analyst", json!("go"))
5842 .await
5843 .expect("dispatch returns Ok for Skip tier (not EvalError::DispatcherError)");
5844
5845 assert!(
5846 is_skip_marker(&out),
5847 "returned value must carry the skip-marker sentinel across the flow-ir boundary: got {out}"
5848 );
5849 assert_eq!(unwrap_skip_marker(&out), Some(inner_verdict));
5850 }
5851
5852 /// `EngineDispatcher::dispatch`'s `RunContext` step-entry log records
5853 /// `status = "skipped"` for a Skip completion (distinct from
5854 /// `"passed"` / `"blocked"`), so post-run inspection of
5855 /// `RunRecord.step_entries` can distinguish flow-continuation-with-
5856 /// binding-write from flow-continuation-without-binding-write.
5857 #[tokio::test]
5858 async fn engine_dispatcher_step_entry_status_is_skipped_for_skip_outcome() {
5859 let inner_verdict = json!({ "verdict": "SKIP" });
5860 let inner_for_worker = inner_verdict.clone();
5861 let factory = RustFnInProcessSpawnerFactory::new().register_fn("analyst", move |_inv| {
5862 let value = wrap_skip_marker(inner_for_worker.clone());
5863 async move {
5864 Ok(WorkerResult {
5865 value,
5866 ok: true,
5867 stats: None,
5868 })
5869 }
5870 });
5871 let def = AgentDef {
5872 name: "analyst".into(),
5873 kind: AgentKind::RustFn,
5874 spec: json!({ "fn_id": "analyst" }),
5875 profile: None,
5876 meta: None,
5877 runner: None,
5878 runner_ref: None,
5879 verdict: None,
5880 };
5881 let spawner = factory.build(&def, None).expect("build");
5882
5883 let engine = Engine::new(EngineCfg::default());
5884 let op_token = engine
5885 .attach("ut-op", Role::Operator, Duration::from_secs(30))
5886 .await
5887 .expect("attach");
5888
5889 // Seed a RunContext with an InMemoryRunStore so the dispatcher
5890 // appends a step_entry we can then read back.
5891 let run_id = RunId::new();
5892 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
5893 run_store
5894 .create(RunRecord {
5895 id: run_id.clone(),
5896 task_id: TaskId::new(),
5897 status: RunStatus::Running,
5898 step_entries: Vec::new(),
5899 degradations: Vec::new(),
5900 operator_sid: None,
5901 result_ref: None,
5902 input_json: None,
5903 created_at: 0,
5904 updated_at: 0,
5905 })
5906 .await
5907 .expect("create run record");
5908 let run_ctx = RunContext::new(run_id.clone(), run_store.clone());
5909
5910 let dispatcher =
5911 EngineDispatcher::with_spawner(engine.clone(), op_token, spawner).with_run(run_ctx);
5912
5913 let out = dispatcher
5914 .dispatch("analyst", json!("go"))
5915 .await
5916 .expect("dispatch ok");
5917 assert!(is_skip_marker(&out));
5918
5919 let record = run_store.get(&run_id).await.expect("run record present");
5920 let step = record
5921 .step_entries
5922 .first()
5923 .expect("at least one step_entry appended for the dispatched step");
5924 assert_eq!(
5925 step.status.as_deref(),
5926 Some("skipped"),
5927 "Skip outcome must record StepEntry.status = \"skipped\""
5928 );
5929 assert_eq!(step.step_ref.as_deref(), Some("analyst"));
5930 }
5931}