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 /// Returns the effective [`mlua_swarm_schema::ContextPolicy`]
2110 /// `AgentContextMiddleware` resolved and snapshotted for `(task_id,
2111 /// attempt)` at spawn time (the same policy already applied to that
2112 /// key's `EngineState.agent_ctx` entry's `.view`, GH #23 fold).
2113 /// Pass-all (`ContextPolicy::default()`) when no entry exists — either
2114 /// a pre-ST5 spawn, or a spawner stack that never layered
2115 /// `AgentContextMiddleware` (fail-open, mirroring [`Self::output_tail`]'s
2116 /// "no entry = empty default" convention).
2117 ///
2118 /// `crates/mlua-swarm-server/src/worker.rs`'s `GET /v1/worker/prompt`
2119 /// handler reads this back to filter `WorkerPayload.context.steps` via
2120 /// `ContextPolicy::allows_step`, without re-deriving the policy from
2121 /// the Blueprint at fetch time (`projection-adapter` ST5).
2122 pub async fn context_policy_for(
2123 &self,
2124 task_id: &StepId,
2125 attempt: u32,
2126 ) -> mlua_swarm_schema::ContextPolicy {
2127 let key = (task_id.clone(), attempt);
2128 self.with_state("context_policy_for", move |s| {
2129 s.agent_ctx
2130 .get(&key)
2131 .map(|e| e.policy.clone())
2132 .unwrap_or_default()
2133 })
2134 .await
2135 .unwrap_or_default()
2136 }
2137
2138 /// GH #23: returns the Blueprint-wide
2139 /// [`crate::core::step_naming::StepNaming`] table snapshotted for
2140 /// `task_id` (the same `Arc` `crate::blueprint::EngineDispatcher::dispatch`
2141 /// stashed into `EngineState.step_namings` at dispatch time —
2142 /// `Self::start_task`'s `StepId`, not the `TaskId` work item). `None`
2143 /// when no entry exists — either the dispatcher was never given a
2144 /// `StepNaming` (`EngineDispatcher::with_step_naming` not called) or
2145 /// the lock could not be acquired; callers are expected to fall back
2146 /// to the pre-GH-#23 runtime union rule in that case (subtask-2/3
2147 /// consumers).
2148 pub async fn step_naming_for(
2149 &self,
2150 task_id: &StepId,
2151 ) -> Option<Arc<crate::core::step_naming::StepNaming>> {
2152 let key = task_id.clone();
2153 self.with_state("step_naming_for", move |s| {
2154 s.step_namings.get(&key).cloned()
2155 })
2156 .await
2157 .ok()
2158 .flatten()
2159 }
2160
2161 /// GH #27 (follow-up to #23): returns the Blueprint-wide
2162 /// [`crate::core::projection_placement::ProjectionPlacement`] resolver
2163 /// snapshotted for `task_id` (the same `Arc`
2164 /// `crate::blueprint::EngineDispatcher::dispatch` stashed into
2165 /// `EngineState.projection_placements` at dispatch time — mirroring
2166 /// [`Self::step_naming_for`]'s contract exactly). `None` when no entry
2167 /// exists — either the dispatcher was never given a
2168 /// `ProjectionPlacement` (`EngineDispatcher::with_projection_placement`
2169 /// not called) or the lock could not be acquired; callers are expected
2170 /// to fall back to `ProjectionPlacement::default()` (byte-compat with
2171 /// the pre-#27 hardcoded layout) in that case.
2172 pub async fn projection_placement_for(
2173 &self,
2174 task_id: &StepId,
2175 ) -> Option<Arc<crate::core::projection_placement::ProjectionPlacement>> {
2176 let key = task_id.clone();
2177 self.with_state("projection_placement_for", move |s| {
2178 s.projection_placements.get(&key).cloned()
2179 })
2180 .await
2181 .ok()
2182 .flatten()
2183 }
2184
2185 /// Returns the [`crate::core::agent_context::AgentContextView`]
2186 /// snapshotted for `(task_id, attempt)`, if `AgentContextMiddleware`
2187 /// stashed one — the same lookup [`Self::fetch_worker_payload`] /
2188 /// [`Self::fetch_worker_payload_trusted`] perform inline, exposed
2189 /// standalone for callers that only need the view (not a full
2190 /// `WorkerPayload`) — e.g. the HTTP debug-plane `GET
2191 /// /v1/tasks/:id/runs/:run/steps*` handlers resolving a
2192 /// materialized-file root for a step *other than* the one currently
2193 /// fetching its own prompt (`projection-adapter` ST5).
2194 pub async fn agent_context_for(
2195 &self,
2196 task_id: &StepId,
2197 attempt: u32,
2198 ) -> Option<crate::core::agent_context::AgentContextView> {
2199 let key = (task_id.clone(), attempt);
2200 self.with_state("agent_context_for", move |s| {
2201 s.agent_ctx.get(&key).map(|e| e.view.clone())
2202 })
2203 .await
2204 .ok()
2205 .flatten()
2206 }
2207
2208 /// Read the current attempt number for a task (server-side lookup, no
2209 /// token verification). Used on `HTTP /v1/worker/result` when the
2210 /// worker omits `attempt` and the server has to fill it in.
2211 pub async fn task_attempt(&self, task_id: &StepId) -> Result<u32, EngineError> {
2212 let task_id = task_id.clone();
2213 self.with_state("task_attempt", move |s| {
2214 s.tasks
2215 .get(&task_id)
2216 .map(|t| t.attempt)
2217 .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))
2218 })
2219 .await?
2220 }
2221
2222 /// Server-side admin API that lets `OperatorSpawner::spawn` bake the
2223 /// rendered `system_prompt` into engine state. There is no verb gate
2224 /// — the only expected caller is inside the spawner. SubAgents fetch
2225 /// this alongside the prompt on the `/v1/worker/prompt` path.
2226 pub async fn bake_worker_system_prompt(
2227 &self,
2228 task_id: &StepId,
2229 attempt: u32,
2230 system: Option<String>,
2231 ) -> Result<(), EngineError> {
2232 let task_id = task_id.clone();
2233 self.with_state("bake_worker_system_prompt", move |s| {
2234 // GH #31: record this agent's most-recently-baked render size
2235 // before `system` is moved into `s.systems.insert` below. Same
2236 // `s.tasks.get(&task_id)` → `.spec.agent` lookup pattern
2237 // `fetch_worker_payload` uses (see its doc for why this keying
2238 // is load-bearing for a later `bp_doctor` route).
2239 if let Some(rendered) = system.as_ref() {
2240 if let Some(agent) = s.tasks.get(&task_id).map(|t| t.spec.agent.clone()) {
2241 s.agent_render_sizes.insert(agent, rendered.len());
2242 }
2243 }
2244 s.systems.insert((task_id, attempt), system);
2245 })
2246 .await?;
2247 Ok(())
2248 }
2249
2250 /// GH #31: the most-recently-baked `system_prompt` render size (in
2251 /// bytes) observed for `agent_name`, if `bake_worker_system_prompt` has
2252 /// ever recorded one — last-write-wins across every `(task_id,
2253 /// attempt)` dispatch of that agent. `None` when no `system_prompt`
2254 /// has ever been baked for this agent name. Read by the `bp_doctor`
2255 /// route this subtask's follow-up adds.
2256 pub async fn agent_last_rendered_size(&self, agent_name: &str) -> Option<usize> {
2257 let agent_name = agent_name.to_string();
2258 self.with_state("agent_last_rendered_size", move |s| {
2259 s.agent_render_sizes.get(&agent_name).copied()
2260 })
2261 .await
2262 .ok()
2263 .flatten()
2264 }
2265
2266 /// GH #31: plain read-through of the baked `system` string for
2267 /// `(task_id, attempt)` from `EngineState.systems`, with no threshold
2268 /// branching. Backs `GET /v1/worker/prompt/system` (the `Http`-mode
2269 /// fetch target `system_ref.uri` points at) — that route needs the
2270 /// exact raw bytes to serve as the response body for the client's
2271 /// sha256 verification, not a `WorkerPayload`-wrapped value.
2272 ///
2273 /// Distinct from `apply_system_ref_threshold` (private, mutates an
2274 /// already-built `WorkerPayload` in place after full construction):
2275 /// this accessor has no threshold logic and is `pub` so
2276 /// `mlua-swarm-server`'s `worker` module can call it directly.
2277 ///
2278 /// Returns `Ok(None)` if no baked system exists for that `(task_id,
2279 /// attempt)` (either the task/attempt has no entry in `s.systems`, or
2280 /// the entry is present but stores `None`) — the caller maps this to
2281 /// a 404.
2282 pub async fn raw_system_prompt(
2283 &self,
2284 task_id: &StepId,
2285 attempt: u32,
2286 ) -> Result<Option<String>, EngineError> {
2287 let task_id = task_id.clone();
2288 self.with_state("raw_system_prompt", move |s| {
2289 s.systems.get(&(task_id, attempt)).cloned().unwrap_or(None)
2290 })
2291 .await
2292 }
2293
2294 /// Fetch an arbitrary named resource previously stored via
2295 /// `set_resource`. Not task-scoped — any valid token with the
2296 /// `FetchData` verb may read any key.
2297 pub async fn fetch_data(&self, token: &CapToken, key: &str) -> Result<Value, EngineError> {
2298 self.verify_token(token, Verb::FetchData).await?;
2299 let key = key.to_string();
2300 self.with_state("fetch_data", move |s| {
2301 s.resources
2302 .get(&key)
2303 .cloned()
2304 .ok_or(EngineError::ResourceNotFound(key))
2305 })
2306 .await?
2307 }
2308
2309 // ───────────────────────────────────────────────────────────────────────
2310 // Output path.
2311 // ───────────────────────────────────────────────────────────────────────
2312
2313 /// Send one output event from inside a `SpawnerAdapter` or worker.
2314 /// Structuring is assumed to be complete by the time we cross the
2315 /// `SpawnerAdapter` boundary; this API just appends to the
2316 /// `OutputStore`, pushes to the `EventLog`, and (for `Final`) emits
2317 /// the `TaskAttemptCompleted` event.
2318 ///
2319 /// This is Domain-side plumbing: it feeds the engine's verdict flow,
2320 /// not the Data-plane store in the `output_store` module. It also
2321 /// does not wake the dispatch path — that is done through the
2322 /// spawner's completion oneshot when the worker terminates.
2323 ///
2324 /// # Submit-time projection sink (subtask-4 / ST2 rework)
2325 ///
2326 /// A `Final` event additionally fans out to the submit-time projection
2327 /// sink ([`Self::materialize_final_submission`]): (a) when
2328 /// [`Self::set_output_store`] has wired a Data-plane
2329 /// [`crate::store::output::OutputStore`], the event is dual-written
2330 /// there (`producer_agent` = `TaskState.spec.agent`, resolved to its
2331 /// GH #23 canonical projection name — see below), and (b) when this
2332 /// task's spawn ran through `AgentContextMiddleware` (so
2333 /// `EngineState.agent_ctx` has a `.view.work_dir` / `.view.project_root`
2334 /// for it), the value is additionally materialized to the
2335 /// [`crate::core::projection_placement::ProjectionPlacement`]
2336 /// resolver's target (byte-compat default layout
2337 /// `<root>/workspace/tasks/<task_id>/ctx/<canonical_agent>.md`) — see
2338 /// `crate::core::projection`'s module doc.
2339 ///
2340 /// **GH #23 subtask-2 (canonical sink):** both writes above key off the
2341 /// canonical name — `Engine::step_naming_for(task_id)`'s
2342 /// `StepNaming::canonical_of_producer(producer_agent)` when a table was
2343 /// snapshotted for this task (`EngineDispatcher::with_step_naming`),
2344 /// else `producer_agent` unchanged (fail-open, byte-identical to
2345 /// pre-GH-#23 behavior — see [`crate::core::step_naming`]'s module
2346 /// doc).
2347 ///
2348 /// **Invariants** (Subtask 4): (1) this sink is fail-open — an
2349 /// unresolved root, an unconfigured `OutputStore`, or either one
2350 /// erroring, only logs a `tracing::warn!` and never turns this
2351 /// `Ok(())` into an `Err`; (2) the wired `OutputStore` stays the single
2352 /// source of truth for cross-step queries — the materialized file is a
2353 /// projection of it, not a second store; (3) core does not depend on
2354 /// `mlua-swarm-server` — everything this sink touches
2355 /// (`crate::store::output` / `crate::core::projection`) already lives
2356 /// in this crate.
2357 ///
2358 /// # `Artifact` dual-write (GH #34 subtask-3 gap fix)
2359 ///
2360 /// An `Artifact` event ALSO fans out to the Data-plane, via
2361 /// [`Self::materialize_artifact_submission`] — general-form: every
2362 /// `Artifact` submitted through this API dual-writes, no name-prefix
2363 /// gate. Unlike `Final`, the dual-write key is the artifact's own
2364 /// `name` field, verbatim — NOT resolved through the GH #23 canonical
2365 /// `StepNaming` table. An artifact's `name` IS its identity (mirrors
2366 /// [`crate::store::output::OutputStore::get_latest_by_name`]'s doc),
2367 /// so no canonicalization applies. Same fail-open discipline as
2368 /// `Final` (Invariant 1 above), but `Artifact` does NOT drive the
2369 /// file-materialize half (b) — artifact findings (e.g.
2370 /// `AfterRunAuditMiddleware`'s `"audit:<step_ref>"`) are observational
2371 /// sidecar data, not a step's own submission a work_dir/project_root
2372 /// projection needs to track. `Progress` / `Partial` events are
2373 /// unaffected — no behavior change.
2374 pub async fn submit_output(
2375 &self,
2376 token: &crate::types::CapToken,
2377 task_id: &StepId,
2378 attempt: u32,
2379 event: crate::worker::output::OutputEvent,
2380 ) -> Result<(), EngineError> {
2381 self.verify_token_for_task(token, crate::types::Verb::EmitOutput, task_id)
2382 .await?;
2383 // GH #51 — completion-time verdict-contract enforcement, embedded
2384 // choke point 2 of 2 (see `Self::verdict_contract_completion_check`'s
2385 // doc). Guarded to `Final` only — the ONLY `OutputEvent` variant a
2386 // verdict contract's completion can meaningfully address; this
2387 // guard is defensive (this function is empirically called with
2388 // `Final` only today, both from `worker.rs`'s `worker_result` and
2389 // from `operator.rs`'s WS fallback) but costs nothing and protects
2390 // against a future non-`Final` caller. Runs BEFORE the
2391 // `output_tail` write immediately below: on `Err`, this returns
2392 // immediately and the write never happens — a rejected value
2393 // never reaches `output_tail` / the flow ctx.
2394 if let crate::worker::output::OutputEvent::Final { content, ok } = &event {
2395 let comparable_value = content_ref_to_comparable_string(content.clone());
2396 self.verdict_contract_completion_check(task_id, attempt, *ok, &comparable_value)
2397 .await?;
2398 }
2399 let task_id_for_apply = task_id.clone();
2400 let event_clone = event.clone();
2401 self.with_state("submit_output", move |s| {
2402 s.output_store
2403 .entry((task_id_for_apply.clone(), attempt))
2404 .or_default()
2405 .push(event_clone.clone());
2406 s.push_event(crate::core::state::Event::WorkerOutput {
2407 task_id: task_id_for_apply,
2408 attempt,
2409 event: event_clone,
2410 });
2411 })
2412 .await?;
2413 match &event {
2414 crate::worker::output::OutputEvent::Final { content, ok } => {
2415 self.materialize_final_submission(task_id, attempt, content, *ok)
2416 .await?;
2417 }
2418 crate::worker::output::OutputEvent::Artifact { name, content } => {
2419 self.materialize_artifact_submission(task_id, attempt, name, content)
2420 .await?;
2421 }
2422 _ => {}
2423 }
2424 Ok(())
2425 }
2426
2427 /// Submit-time projection sink (subtask-4 / ST2 rework) shared by
2428 /// [`Self::submit_output`] and [`Self::submit_worker_result_trusted`].
2429 /// Best-effort / fail-open throughout (see `submit_output`'s doc
2430 /// Invariants): every failure path only `tracing::warn!`s and returns.
2431 ///
2432 /// Reads `(producer_agent, view)` via one read-only [`Self::with_state`]
2433 /// call — `producer_agent` off `TaskState.spec.agent`, `view` (the
2434 /// full [`crate::core::agent_context::AgentContextView`]) off
2435 /// `EngineState.agent_ctx[(task_id, attempt)]`, the same snapshot
2436 /// `crate::middleware::agent_context::AgentContextMiddleware` writes at
2437 /// spawn time — then does its actual (dual-write / file-write) work
2438 /// *outside* that lock, so a slow disk write or Data-plane store call
2439 /// never holds up unrelated `Engine::with_state` callers. `root` itself
2440 /// is resolved from `view` AFTER the lock via
2441 /// [`crate::core::projection_placement::ProjectionPlacement::resolve_root`]
2442 /// (GH #27, follow-up to #23) — the SAME resolver
2443 /// [`Self::step_naming_for`]'s sibling accessor
2444 /// [`Self::projection_placement_for`] snapshotted at dispatch time, so
2445 /// this sink's root-preference / fallback order is identical to the
2446 /// server read-back and the spawn-time pointer.
2447 async fn materialize_final_submission(
2448 &self,
2449 task_id: &StepId,
2450 attempt: u32,
2451 content: &crate::worker::output::ContentRef,
2452 ok: bool,
2453 ) -> Result<(), EngineError> {
2454 let server_policy = self.cfg().check_policy;
2455 let task_id_for_lookup = task_id.clone();
2456 let lookup = self
2457 .with_state("materialize_final_submission.lookup", move |s| {
2458 let entry = s.tasks.get(&task_id_for_lookup);
2459 let producer_agent = entry.map(|t| t.spec.agent.clone());
2460 let task_policy = entry.and_then(|t| t.spec.check_policy);
2461 let view = s
2462 .agent_ctx
2463 .get(&(task_id_for_lookup.clone(), attempt))
2464 .map(|e| e.view.clone());
2465 (producer_agent, task_policy, view)
2466 })
2467 .await;
2468 // Per-task `TaskSpec.check_policy` (ST1c) wins
2469 // over the server-wide `EngineCfg.check_policy` when set — a
2470 // per-run override forwarded from the launch entry point (see
2471 // `TaskLaunchRequest.check_policy` /
2472 // `TaskLaunchInput.check_policy`). `None` leaves the server
2473 // default in effect (backward compat).
2474 let policy = lookup
2475 .as_ref()
2476 .ok()
2477 .and_then(|(_, tp, _)| *tp)
2478 .unwrap_or(server_policy);
2479 let (producer_agent, view) = match lookup.map(|(pa, _, view)| (pa, view)) {
2480 Ok(pair) => pair,
2481 Err(err) => {
2482 if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2483 tracing::warn!(
2484 %task_id,
2485 error = %err,
2486 "submit-time projection sink: state lookup failed; skipping (fail-open)"
2487 );
2488 }
2489 apply_check_policy(
2490 policy,
2491 "submit-time projection sink: state lookup",
2492 "state lookup failed; skipping (fail-open)",
2493 )?;
2494 return Ok(());
2495 }
2496 };
2497 let Some(producer_agent) = producer_agent else {
2498 // Defensive only: `task_id` is always a just-looked-up task at
2499 // every real call site. No task, no addressable producer name
2500 // — nothing to project. Not gated by `CheckPolicy` — a missing
2501 // task is an intentional early-exit path, not a fail-open
2502 // condition to surface.
2503 return Ok(());
2504 };
2505 let placement = self
2506 .projection_placement_for(task_id)
2507 .await
2508 .unwrap_or_default();
2509 let root = view.and_then(|v| placement.resolve_root(&v));
2510
2511 // GH #23 subtask-2: resolve `producer_agent` to its canonical
2512 // projection name via the Blueprint-wide `StepNaming` table
2513 // snapshotted at dispatch time (`Engine::step_naming_for`). Both
2514 // write paths below ((a) data-plane, (b) file stem) use the
2515 // *canonical* name — `StepNaming::canonical_of_producer` returns
2516 // `producer_agent` unchanged for undeclared steps (byte-identical
2517 // to pre-GH-#23 behavior), and `None` (no table for this
2518 // `task_id`, e.g. a spawn that never went through
2519 // `EngineDispatcher::with_step_naming`) is a defensive fail-open
2520 // to the raw `producer_agent`, same discipline as the rest of this
2521 // sink.
2522 let canonical_agent = self
2523 .step_naming_for(task_id)
2524 .await
2525 .and_then(|naming| {
2526 naming
2527 .canonical_of_producer(&producer_agent)
2528 .map(str::to_string)
2529 })
2530 .unwrap_or_else(|| producer_agent.clone());
2531
2532 // (a) Data-plane dual-write, when an OutputStore backend is wired.
2533 if let Some(store) = self.output_store_backend() {
2534 if let Err(err) = store
2535 .append(
2536 task_id.as_str(),
2537 attempt,
2538 &canonical_agent,
2539 crate::worker::output::OutputEvent::Final {
2540 content: content.clone(),
2541 ok,
2542 },
2543 Vec::new(),
2544 )
2545 .await
2546 {
2547 if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2548 tracing::warn!(
2549 %task_id,
2550 agent = %producer_agent,
2551 canonical = %canonical_agent,
2552 error = %err,
2553 "submit-time projection sink: OutputStore dual-write failed (fail-open)"
2554 );
2555 }
2556 apply_check_policy(
2557 policy,
2558 "submit-time projection sink: OutputStore dual-write",
2559 "OutputStore dual-write failed (fail-open)",
2560 )?;
2561 }
2562 }
2563
2564 // (b) File materialize, when a root resolved.
2565 let Some(root) = root else {
2566 if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2567 tracing::warn!(
2568 %task_id,
2569 agent = %producer_agent,
2570 canonical = %canonical_agent,
2571 "submit-time projection sink: no work_dir/project_root resolved; skipping file materialize (fail-open)"
2572 );
2573 }
2574 apply_check_policy(
2575 policy,
2576 "submit-time projection sink: file materialize",
2577 "no work_dir/project_root resolved; skipping file materialize (fail-open)",
2578 )?;
2579 return Ok(());
2580 };
2581 let value = match content {
2582 crate::worker::output::ContentRef::Inline { value } => value.clone(),
2583 crate::worker::output::ContentRef::FileRef {
2584 path,
2585 mime,
2586 size_hint,
2587 } => serde_json::json!({
2588 "file_ref": path.to_string_lossy(),
2589 "mime": mime,
2590 "size_hint": size_hint,
2591 }),
2592 };
2593 let key = crate::core::projection::ProjectionKey {
2594 task_id: task_id.to_string(),
2595 run_id: None,
2596 step: Some(canonical_agent.clone()),
2597 path: None,
2598 };
2599 let adapter = crate::core::projection::FileProjectionAdapter::with_placement(
2600 root,
2601 (*placement).clone(),
2602 );
2603 if let Err(err) = adapter.materialize_submission(&key, &value, attempt, ok) {
2604 if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2605 tracing::warn!(
2606 %task_id,
2607 agent = %producer_agent,
2608 canonical = %canonical_agent,
2609 error = %err,
2610 "submit-time projection sink: file materialize failed (fail-open)"
2611 );
2612 }
2613 apply_check_policy(
2614 policy,
2615 "submit-time projection sink: file materialize",
2616 "file materialize failed (fail-open)",
2617 )?;
2618 }
2619 Ok(())
2620 }
2621
2622 /// Submit-time projection sink for `OutputEvent::Artifact` (GH #34
2623 /// subtask-3, later extended to drive the file half too). Two halves, the
2624 /// [`Self::materialize_final_submission`] mirror for staged named parts:
2625 ///
2626 /// - **Data-plane dual-write** — when [`Self::set_output_store`] has
2627 /// wired a [`crate::store::output::OutputStore`], the artifact
2628 /// dual-writes there under its own `name`, verbatim (general form:
2629 /// every `Artifact` staged via [`Self::submit_output`] /
2630 /// [`Self::stage_worker_artifact_trusted`] materializes this way, no
2631 /// name-prefix gate).
2632 /// - **File materialize** — when a `root` resolves off the spawn-time
2633 /// [`crate::core::agent_context::AgentContextView`], the part's
2634 /// content is written raw to `<ctx-dir>/<name>` via
2635 /// [`crate::core::projection::FileProjectionAdapter::materialize_part`].
2636 /// That file is the IN file the *next* Agent step reads: materializing
2637 /// a Step's OUTPUT to disk is the
2638 /// [`crate::core::projection::FileProjectionAdapter`]'s
2639 /// responsibility, and a staged named part is as much an OUTPUT the
2640 /// next step consumes as a `Final` is — so the sink materializes it
2641 /// too, rather than leaving parts Data-plane-only.
2642 ///
2643 /// Unlike the Final sink, no `StepNaming` canonicalization is applied:
2644 /// an artifact's `name` already IS the key both halves address (it
2645 /// names the file directly, extension included — `plan.md` — so
2646 /// `materialize_part` writes it verbatim, not through the `<stem>.md`
2647 /// synthesis the Final sink's canonical-agent path uses).
2648 ///
2649 /// Fail-open throughout, the same `check_policy` cascade as
2650 /// [`Self::materialize_final_submission`]: a per-task lookup error falls
2651 /// back to the server default (and a `None` view ⇒ the file half's
2652 /// unresolved-root path), an unconfigured `OutputStore` skips the
2653 /// dual-write, an unresolved root skips the file half, and a
2654 /// dual-write / file-write / name-guard error only `tracing::warn!`s
2655 /// (`Silent` suppresses even that) before applying [`apply_check_policy`]
2656 /// (`Strict` surfaces an [`EngineError`], `Warn` / `Silent` return
2657 /// `Ok(())`) — a staged part never turns a would-have-succeeded submit
2658 /// into a failure under the default policy.
2659 async fn materialize_artifact_submission(
2660 &self,
2661 task_id: &StepId,
2662 attempt: u32,
2663 name: &str,
2664 content: &crate::worker::output::ContentRef,
2665 ) -> Result<(), EngineError> {
2666 // Per-task `TaskSpec.check_policy` override + the `AgentContextView`
2667 // snapshot, resolved in ONE read-only `with_state` (the same lock
2668 // the policy lookup already needed — no extra `with_state` for the
2669 // view). Silent per-task lookup failure (`with_state` error) falls
2670 // back to the server-wide default and a `None` view (⇒ the file
2671 // half's own unresolved-root fail-open path); this sink never
2672 // surfaces the lookup error itself as a step failure.
2673 let server_policy = self.cfg().check_policy;
2674 let task_id_for_lookup = task_id.clone();
2675 let lookup = self
2676 .with_state("materialize_artifact_submission.lookup", move |s| {
2677 let task_policy = s
2678 .tasks
2679 .get(&task_id_for_lookup)
2680 .and_then(|t| t.spec.check_policy);
2681 let view = s
2682 .agent_ctx
2683 .get(&(task_id_for_lookup.clone(), attempt))
2684 .map(|e| e.view.clone());
2685 (task_policy, view)
2686 })
2687 .await
2688 .ok();
2689 let policy = lookup
2690 .as_ref()
2691 .and_then(|(tp, _)| *tp)
2692 .unwrap_or(server_policy);
2693 let view = lookup.and_then(|(_, view)| view);
2694
2695 // (a) Data-plane dual-write, when an OutputStore backend is wired —
2696 // the artifact's own `name` is its Data-plane key (no
2697 // canonicalization, unlike the Final sink's `StepNaming`
2698 // resolution).
2699 if let Some(store) = self.output_store_backend() {
2700 if let Err(err) = store
2701 .append(
2702 task_id.as_str(),
2703 attempt,
2704 name,
2705 crate::worker::output::OutputEvent::Artifact {
2706 name: name.to_string(),
2707 content: content.clone(),
2708 },
2709 Vec::new(),
2710 )
2711 .await
2712 {
2713 if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2714 tracing::warn!(
2715 %task_id,
2716 artifact = %name,
2717 error = %err,
2718 "submit-time projection sink: OutputStore dual-write failed for Artifact (fail-open)"
2719 );
2720 }
2721 apply_check_policy(
2722 policy,
2723 "submit-time projection sink: Artifact OutputStore dual-write",
2724 "OutputStore dual-write failed for Artifact (fail-open)",
2725 )?;
2726 }
2727 }
2728
2729 // (b) File materialize, when a root resolved — writes the staged
2730 // part raw to `<ctx-dir>/<name>`, the IN file the next Agent step
2731 // reads (see `FileProjectionAdapter::materialize_part`'s doc for
2732 // why raw / why the name is verbatim). A name-guard violation lands
2733 // on the same fail-open path as any other write error below.
2734 let placement = self
2735 .projection_placement_for(task_id)
2736 .await
2737 .unwrap_or_default();
2738 let Some(root) = view.and_then(|v| placement.resolve_root(&v)) else {
2739 if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2740 tracing::warn!(
2741 %task_id,
2742 artifact = %name,
2743 "submit-time projection sink: no work_dir/project_root resolved; skipping part file materialize (fail-open)"
2744 );
2745 }
2746 apply_check_policy(
2747 policy,
2748 "submit-time projection sink: part file materialize",
2749 "no work_dir/project_root resolved; skipping part file materialize (fail-open)",
2750 )?;
2751 return Ok(());
2752 };
2753 let value = match content {
2754 crate::worker::output::ContentRef::Inline { value } => value.clone(),
2755 crate::worker::output::ContentRef::FileRef {
2756 path,
2757 mime,
2758 size_hint,
2759 } => serde_json::json!({
2760 "file_ref": path.to_string_lossy(),
2761 "mime": mime,
2762 "size_hint": size_hint,
2763 }),
2764 };
2765 let adapter = crate::core::projection::FileProjectionAdapter::with_placement(
2766 root,
2767 (*placement).clone(),
2768 );
2769 if let Err(err) = adapter.materialize_part(task_id.as_str(), name, &value) {
2770 if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2771 tracing::warn!(
2772 %task_id,
2773 artifact = %name,
2774 error = %err,
2775 "submit-time projection sink: part file materialize failed (fail-open)"
2776 );
2777 }
2778 apply_check_policy(
2779 policy,
2780 "submit-time projection sink: part file materialize",
2781 "part file materialize failed (fail-open)",
2782 )?;
2783 }
2784 Ok(())
2785 }
2786
2787 /// Snapshot the entire output tail for a given `(task_id, attempt)`.
2788 /// Used by the dispatch path when pulling `Final`, and by observers
2789 /// reading the trace.
2790 pub async fn output_tail(
2791 &self,
2792 task_id: &StepId,
2793 attempt: u32,
2794 ) -> Vec<crate::worker::output::OutputEvent> {
2795 let key = (task_id.clone(), attempt);
2796 self.with_state("output_tail", move |s| {
2797 s.output_store.get(&key).cloned().unwrap_or_default()
2798 })
2799 .await
2800 .unwrap_or_default()
2801 }
2802
2803 /// Record an interim `last_result` for `task_id` without changing its
2804 /// `status`. Distinct from the terminal `Final` output event handled
2805 /// through `submit_output` / `dispatch_attempt_with`.
2806 pub async fn post_result(
2807 &self,
2808 token: &CapToken,
2809 task_id: &StepId,
2810 result: Value,
2811 ) -> Result<(), EngineError> {
2812 self.verify_token_for_task(token, Verb::PostResult, task_id)
2813 .await?;
2814 let task_id = task_id.clone();
2815 let result_clone = result.clone();
2816 self.with_state("post_result", move |s| {
2817 let task = s
2818 .tasks
2819 .get_mut(&task_id)
2820 .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))?;
2821 task.last_result = Some(result_clone);
2822 task.updated_at = now_unix();
2823 Ok::<(), EngineError>(())
2824 })
2825 .await??;
2826 Ok(())
2827 }
2828
2829 /// Store a named resource value, retrievable later via `fetch_data`.
2830 /// No token is required — this is a server-side/admin-style setter
2831 /// (mirrors `bake_worker_system_prompt`).
2832 pub async fn set_resource(
2833 &self,
2834 key: impl Into<String>,
2835 value: Value,
2836 ) -> Result<(), EngineError> {
2837 let key = key.into();
2838 self.with_state("set_resource", move |s| {
2839 s.resources.insert(key, value);
2840 })
2841 .await?;
2842 Ok(())
2843 }
2844
2845 // ═══════════════════════════════════════════════════════════════════════
2846 // Senior suspend / resume
2847 // ═══════════════════════════════════════════════════════════════════════
2848
2849 /// Ask a question of the Senior, mark the task `Suspended`, and
2850 /// return a `ResumeKey`. The suspended state persists until another
2851 /// task calls `resume(key, answer)`.
2852 ///
2853 /// Resume-side waiting is `Notify`-based, so a caller (typically
2854 /// MainAI) can detach, reattach from a different process, and still
2855 /// pull the answer out via `await_resume(key, timeout)` — the answer
2856 /// is stored inside `EngineState`.
2857 pub async fn query_senior(
2858 &self,
2859 token: &CapToken,
2860 task_id: &StepId,
2861 question: Value,
2862 ) -> Result<ResumeKey, EngineError> {
2863 self.verify_token(token, Verb::QuerySenior).await?;
2864 let task_id = task_id.clone();
2865 let key = ResumeKey::for_senior(&task_id);
2866 let task_notify = self
2867 .with_state("query_senior.notify_ensure", |s| {
2868 s.ensure_task_notify(&task_id)
2869 })
2870 .await?;
2871
2872 let key_clone = key.clone();
2873 let task_id_inner = task_id.clone();
2874 let question_clone = question.clone();
2875 self.with_state("query_senior.suspend", move |s| {
2876 let task = s
2877 .tasks
2878 .get_mut(&task_id_inner)
2879 .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))?;
2880 task.status = TaskStatus::Suspended;
2881 task.suspended_on = Some(key_clone.clone());
2882 task.updated_at = now_unix();
2883 s.pending_resumes
2884 .insert(key_clone.clone(), ResumePending::new());
2885 s.push_event(Event::SeniorQueried {
2886 task_id: task_id_inner.clone(),
2887 question: question_clone.clone(),
2888 });
2889 s.push_event(Event::TaskSuspended {
2890 task_id: task_id_inner.clone(),
2891 key: key_clone.clone(),
2892 });
2893 Ok::<(), EngineError>(())
2894 })
2895 .await??;
2896
2897 // Notify callers waiting for a task status change (Running → Suspended).
2898 task_notify.notify_waiters();
2899
2900 let _ = self
2901 .inner
2902 .event_tx
2903 .send(Event::SeniorQueried { task_id, question });
2904 Ok(key)
2905 }
2906
2907 /// Store the answer for a `ResumeKey` in `EngineState` and wake the
2908 /// waiting caller via `Notify`. Also flips the suspended task's
2909 /// status back to `Running` and fires the per-task notifier.
2910 pub async fn resume(&self, key: ResumeKey, answer: Value) -> Result<(), EngineError> {
2911 let answer_for_state = answer.clone();
2912 let answer_for_event = answer.clone();
2913 let key_clone = key.clone();
2914 let (notify, task_notify, task_id_opt) = self
2915 .with_state("resume.set", move |s| {
2916 let pending = s
2917 .pending_resumes
2918 .get_mut(&key_clone)
2919 .ok_or(EngineError::ResumeKeyNotFound)?;
2920 pending.answer = Some(answer_for_state);
2921 let notify = pending.notify.clone();
2922
2923 let task_id = s
2924 .tasks
2925 .iter()
2926 .find(|(_, t)| t.suspended_on.as_ref() == Some(&key_clone))
2927 .map(|(id, _)| id.clone());
2928
2929 let task_notify = task_id.as_ref().map(|tid| s.ensure_task_notify(tid));
2930
2931 if let Some(tid) = &task_id {
2932 if let Some(task) = s.tasks.get_mut(tid) {
2933 task.suspended_on = None;
2934 task.status = TaskStatus::Running;
2935 task.updated_at = now_unix();
2936 }
2937 s.push_event(Event::TaskResumed {
2938 task_id: tid.clone(),
2939 key: key_clone.clone(),
2940 });
2941 s.push_event(Event::SeniorAnswered {
2942 task_id: tid.clone(),
2943 answer: answer_for_event.clone(),
2944 });
2945 }
2946 Ok::<_, EngineError>((notify, task_notify, task_id))
2947 })
2948 .await??;
2949
2950 // Outside the lock: notify_waiters for both the ResumePending and task-status waits.
2951 notify.notify_waiters();
2952 if let Some(n) = task_notify {
2953 n.notify_waiters();
2954 }
2955
2956 if let Some(tid) = task_id_opt {
2957 let _ = self
2958 .inner
2959 .event_tx
2960 .send(Event::TaskResumed { task_id: tid, key });
2961 }
2962 Ok(())
2963 }
2964
2965 /// Wait for the resume answer. Even if the caller (an Operator)
2966 /// detached and reattached, the answer is available immediately here
2967 /// — if it was already stored, this returns without waiting on the
2968 /// notifier.
2969 ///
2970 /// `timeout = Duration::ZERO` performs an instant check without
2971 /// waiting.
2972 pub async fn await_resume(
2973 &self,
2974 key: ResumeKey,
2975 timeout: Duration,
2976 ) -> Result<Value, EngineError> {
2977 // (1) Under the lock: clone the notify handle and check for an existing answer.
2978 let key_clone = key.clone();
2979 let (notify, existing) = self
2980 .with_state("await_resume.snapshot", move |s| {
2981 let pending = s
2982 .pending_resumes
2983 .get(&key_clone)
2984 .ok_or(EngineError::ResumeKeyNotFound)?;
2985 Ok::<_, EngineError>((pending.notify.clone(), pending.answer.clone()))
2986 })
2987 .await??;
2988
2989 // (2) If an answer has already been stored, return immediately (detach / reattach pattern).
2990 if let Some(v) = existing {
2991 return Ok(v);
2992 }
2993
2994 // (3) Outside the lock: wait on the notify with a timeout.
2995 if timeout.is_zero() {
2996 return Err(EngineError::PollTimeout);
2997 }
2998 let waited = tokio::time::timeout(timeout, notify.notified()).await;
2999 if waited.is_err() {
3000 return Err(EngineError::PollTimeout);
3001 }
3002
3003 // (4) Under the lock: re-read the answer (should be present now that we were notified).
3004 let key_clone = key.clone();
3005 self.with_state("await_resume.read", move |s| {
3006 let pending = s
3007 .pending_resumes
3008 .get(&key_clone)
3009 .ok_or(EngineError::ResumeKeyNotFound)?;
3010 pending
3011 .answer
3012 .clone()
3013 .ok_or_else(|| EngineError::Internal("notified but answer missing".into()))
3014 })
3015 .await?
3016 }
3017
3018 // ═══════════════════════════════════════════════════════════════════════
3019 // poll_task — the "wait" path that waits for task-status changes (works for long-poll and regular wait).
3020 // ═══════════════════════════════════════════════════════════════════════
3021
3022 /// Wait until the task's status **transitions to terminal or
3023 /// `Suspended`**, then return the latest `TaskState`. Returns
3024 /// immediately if the task is already in a terminal state.
3025 /// Exceeding the timeout returns `EngineError::PollTimeout`.
3026 ///
3027 /// A `hold` of `Duration::from_secs(0)` returns a snapshot immediately
3028 /// (no wait). Larger holds — tens of minutes up to days — are fine;
3029 /// the wait state is kept in memory inside the engine and does not
3030 /// degrade.
3031 pub async fn poll_task(
3032 &self,
3033 token: &CapToken,
3034 task_id: &StepId,
3035 hold: Duration,
3036 ) -> Result<TaskState, EngineError> {
3037 self.verify_token_for_task(token, Verb::PollTask, task_id)
3038 .await?;
3039 let task_id_inner = task_id.clone();
3040
3041 // (1) Under the lock: take a snapshot and clone task_notify.
3042 let (state, notify) = self
3043 .with_state("poll_task.snapshot", move |s| {
3044 let task = s
3045 .tasks
3046 .get(&task_id_inner)
3047 .cloned()
3048 .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))?;
3049 let notify = s.ensure_task_notify(&task_id_inner);
3050 Ok::<_, EngineError>((task, notify))
3051 })
3052 .await??;
3053
3054 // (2) Immediate-return condition: already terminal / Suspended (nothing left to wait on).
3055 if matches!(
3056 state.status,
3057 TaskStatus::Pass | TaskStatus::Blocked | TaskStatus::Cancelled | TaskStatus::Suspended
3058 ) {
3059 return Ok(state);
3060 }
3061 if hold.is_zero() {
3062 return Ok(state);
3063 }
3064
3065 // (3) Outside the lock: wait on Notify with a timeout.
3066 let waited = tokio::time::timeout(hold, notify.notified()).await;
3067 if waited.is_err() {
3068 return Err(EngineError::PollTimeout);
3069 }
3070
3071 // (4) Under the lock: take a fresh snapshot.
3072 let task_id_inner = task_id.clone();
3073 self.with_state("poll_task.reread", move |s| {
3074 s.tasks
3075 .get(&task_id_inner)
3076 .cloned()
3077 .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))
3078 })
3079 .await?
3080 }
3081
3082 // ═══════════════════════════════════════════════════════════════════════
3083 // Background: heartbeat miss → detach loop
3084 // ═══════════════════════════════════════════════════════════════════════
3085
3086 /// Background loop that scans sessions every `heartbeat_interval` and
3087 /// flips `attached = false` on any session whose `last_seen` exceeds
3088 /// `heartbeat_miss_threshold * interval`.
3089 ///
3090 /// The tasks themselves are kept (assuming
3091 /// `keepalive_on_idle = true`), so another client can reattach with
3092 /// the same token and resume immediately. Dropping the returned
3093 /// `JoinHandle` does not stop the loop — the handle exists so callers
3094 /// who want to abort can hold onto it.
3095 pub fn start_detach_loop(&self) -> tokio::task::JoinHandle<()> {
3096 let engine = self.clone();
3097 let cfg = self.inner.cfg.long_hold.clone();
3098 let interval = cfg.heartbeat_interval;
3099 let miss_secs = cfg.heartbeat_interval.as_secs() * cfg.heartbeat_miss_threshold as u64;
3100
3101 tokio::spawn(async move {
3102 let mut ticker = tokio::time::interval(interval);
3103 ticker.tick().await; // first tick is immediate
3104 loop {
3105 ticker.tick().await;
3106 let now = now_unix();
3107 let detached = engine
3108 .with_state("detach_loop.scan", |s| {
3109 let mut detached = Vec::new();
3110 for (sid, sess) in s.sessions.iter_mut() {
3111 if !sess.attached {
3112 continue;
3113 }
3114 if now.saturating_sub(sess.last_seen) >= miss_secs {
3115 sess.attached = false;
3116 detached.push(sid.clone());
3117 }
3118 }
3119 for sid in &detached {
3120 s.push_event(Event::SessionDetached {
3121 session_id: sid.clone(),
3122 });
3123 }
3124 detached
3125 })
3126 .await
3127 .unwrap_or_default();
3128 for sid in detached {
3129 let _ = engine
3130 .inner
3131 .event_tx
3132 .send(Event::SessionDetached { session_id: sid });
3133 }
3134 }
3135 })
3136 }
3137
3138 /// Helper: wake a task whose status has changed. Called from the
3139 /// method body outside the lock.
3140 async fn wake_task(&self, task_id: &StepId) -> Result<(), EngineError> {
3141 let task_id = task_id.clone();
3142 let notify_opt = self
3143 .with_state("wake_task.get_notify", move |s| {
3144 s.task_notifies.get(&task_id).cloned()
3145 })
3146 .await?;
3147 if let Some(n) = notify_opt {
3148 n.notify_waiters();
3149 }
3150 Ok(())
3151 }
3152}
3153
3154/// Decide what a submit-time projection sink should do at a fail-open
3155/// branch given the configured [`crate::core::config::CheckPolicy`].
3156///
3157/// Returns `Ok(())` under [`CheckPolicy::Silent`] and
3158/// [`CheckPolicy::Warn`] — the caller continues with fail-open. Returns
3159/// [`EngineError::CheckPolicyStrict`] under [`CheckPolicy::Strict`],
3160/// carrying the caller-supplied `context` (call-site identifier) and
3161/// `message` (the pre-existing warn-log message literal, preserved
3162/// verbatim for log parse compatibility).
3163///
3164/// This helper deliberately does **not** call `tracing::warn!` itself —
3165/// the caller is responsible for firing the existing warn! (with its
3166/// full structured-field payload — `%task_id`, `agent`, `canonical`,
3167/// `error`, etc.) under `Warn` mode, and for skipping the warn! under
3168/// `Silent` mode. Keeping the warn! at the call site preserves the
3169/// exact structured-field shape every existing log-parse consumer sees;
3170/// forwarding it through the helper would either drop those fields or
3171/// require a macro (deferred, see subtask-1b).
3172///
3173/// Design intent: the fail-open discipline of every submit-time
3174/// projection sink is byte-identical to the pre-`CheckPolicy` behaviour
3175/// under the default [`CheckPolicy::Warn`]. `Silent` is a per-run opt-in
3176/// to suppress noise (e.g., a caller that has already verified upstream
3177/// invariants); `Strict` is a per-run opt-in to fail loudly (e.g., a
3178/// caller that requires all parts to materialize). See
3179/// [`crate::core::config::CheckPolicy`] for the "state dirty on fail"
3180/// semantics of `Strict`.
3181pub(crate) fn apply_check_policy(
3182 policy: crate::core::config::CheckPolicy,
3183 context: &str,
3184 message: &str,
3185) -> Result<(), EngineError> {
3186 match policy {
3187 crate::core::config::CheckPolicy::Silent | crate::core::config::CheckPolicy::Warn => Ok(()),
3188 crate::core::config::CheckPolicy::Strict => Err(EngineError::CheckPolicyStrict {
3189 context: context.to_string(),
3190 message: message.to_string(),
3191 }),
3192 }
3193}
3194
3195#[cfg(test)]
3196mod check_policy_helper_tests {
3197 use super::apply_check_policy;
3198 use crate::core::config::CheckPolicy;
3199 use crate::core::errors::EngineError;
3200
3201 /// `Silent` returns `Ok(())` without producing an error. Log
3202 /// suppression (the "no `tracing::warn!`" half of the semantics) is
3203 /// enforced at the call site, not inside the helper — see the
3204 /// helper's doc comment for why.
3205 #[test]
3206 fn silent_returns_ok() {
3207 let result = apply_check_policy(CheckPolicy::Silent, "call/site", "sink message");
3208 assert!(matches!(result, Ok(())));
3209 }
3210
3211 /// `Warn` (the default) returns `Ok(())` — the caller continues
3212 /// with fail-open, having already fired its own `tracing::warn!`
3213 /// with the full structured-field payload.
3214 #[test]
3215 fn warn_returns_ok() {
3216 let result = apply_check_policy(CheckPolicy::Warn, "call/site", "sink message");
3217 assert!(matches!(result, Ok(())));
3218 }
3219
3220 /// `Strict` returns
3221 /// [`EngineError::CheckPolicyStrict`] with `context` and `message`
3222 /// copied verbatim from the caller — the completion route surfaces
3223 /// this as a step / launch error so a caller that has opted in can
3224 /// fail fast instead of proceeding with a partially-realized
3225 /// submission.
3226 #[test]
3227 fn strict_returns_error_with_context_and_message() {
3228 let result = apply_check_policy(
3229 CheckPolicy::Strict,
3230 "submit-time projection sink: file materialize",
3231 "no work_dir/project_root resolved; skipping file materialize (fail-open)",
3232 );
3233 match result {
3234 Err(EngineError::CheckPolicyStrict { context, message }) => {
3235 assert_eq!(context, "submit-time projection sink: file materialize");
3236 assert_eq!(
3237 message,
3238 "no work_dir/project_root resolved; skipping file materialize (fail-open)"
3239 );
3240 }
3241 other => panic!("expected CheckPolicyStrict, got {:?}", other),
3242 }
3243 }
3244}
3245
3246// ─── UT: issue #14 — token store keyed by fingerprint, not nonce ────────────
3247#[cfg(test)]
3248mod token_fingerprint_store_tests {
3249 use super::*;
3250
3251 /// A token that was never attached fails verify with a `TokenNotFound`
3252 /// that carries the fingerprint — never the nonce. The error string can
3253 /// surface in HTTP error bodies, so this is the secret-hygiene contract.
3254 #[tokio::test]
3255 async fn verify_unknown_token_reports_fingerprint_not_nonce() {
3256 let engine = Engine::new(EngineCfg::default());
3257 // Signed by the engine's own signer (sig passes) but never inserted
3258 // into the store — verify must fail at step (4), the store lookup.
3259 let token = engine.signer().session(
3260 "ghost",
3261 Role::Operator,
3262 vec!["*".into()],
3263 Duration::from_secs(60),
3264 );
3265 let err = engine
3266 .verify_token(&token, Verb::ReadTaskState)
3267 .await
3268 .expect_err("token is not in the store");
3269 let msg = err.to_string();
3270 assert!(
3271 msg.contains(&token.fingerprint()),
3272 "error must carry the fingerprint: {msg}"
3273 );
3274 assert!(
3275 !msg.contains(&token.nonce),
3276 "error must not leak the nonce: {msg}"
3277 );
3278 }
3279
3280 /// attach → verify → heartbeat → detach all resolve the session /
3281 /// token record through fingerprint keys (mint/verify lifecycle
3282 /// regression guard for the issue #14 key migration).
3283 #[tokio::test]
3284 async fn attach_verify_heartbeat_detach_cycle_with_fp_keying() {
3285 let engine = Engine::new(EngineCfg::default());
3286 let token = engine
3287 .attach("op-1", Role::Operator, Duration::from_secs(60))
3288 .await
3289 .expect("attach");
3290 engine
3291 .verify_token(&token, Verb::ReadTaskState)
3292 .await
3293 .expect("verify consumes via fp key");
3294 engine
3295 .heartbeat(&token)
3296 .await
3297 .expect("heartbeat finds the session by fp");
3298 engine
3299 .detach(&token)
3300 .await
3301 .expect("detach finds the session by fp");
3302 }
3303}
3304
3305// ─── UT: `OperatorKind` "Runtime Global" tier — `Option` semantics ─────────
3306//
3307// Regression coverage for the "explicit Automate is indistinguishable from
3308// unspecified" defect: `OperatorSession.operator_kind` (and the
3309// `attach_with_ids` `kind` parameter it stores) is `Option<OperatorKind>`,
3310// so `Some(Automate)` is an explicit Runtime Global request that must
3311// outrank `bp_global`, while `None` must let `bp_global` decide. Exercises
3312// the real `resolve_operator_info` cascade path (not just
3313// `collapse_operator_kind` in isolation), attaching via `attach_with_ids`
3314// exactly as `TaskLaunchService::launch` does.
3315#[cfg(test)]
3316mod resolve_operator_info_runtime_global_tests {
3317 use super::*;
3318
3319 async fn attach_and_resolve(
3320 runtime_global: Option<OperatorKind>,
3321 bp_global: Option<OperatorKind>,
3322 ) -> OperatorInfo {
3323 let engine = Engine::new(EngineCfg::default());
3324 let token = engine
3325 .attach_with_ids(
3326 "ut-op",
3327 Role::Operator,
3328 Duration::from_secs(30),
3329 runtime_global,
3330 None,
3331 None,
3332 None,
3333 HashMap::new(),
3334 HashMap::new(),
3335 bp_global,
3336 )
3337 .await
3338 .expect("attach_with_ids ok");
3339 let session = engine
3340 .with_state("test.find_session", |s| {
3341 s.sessions
3342 .values()
3343 .find(|sess| sess.token_fp == token.fingerprint())
3344 .cloned()
3345 })
3346 .await
3347 .expect("with_state ok")
3348 .expect("session present after attach_with_ids");
3349 engine.resolve_operator_info(&session, "agent-x").await
3350 }
3351
3352 #[tokio::test]
3353 async fn explicit_some_automate_outranks_bp_global_main_ai() {
3354 // Runtime Global explicitly requests Automate; bp_global is MainAi.
3355 // The explicit `Some(Automate)` must win — this is exactly the case
3356 // the old `== OperatorKind::default()` convention got wrong (it
3357 // could not tell "explicitly Automate" from "unspecified" and would
3358 // have let `bp_global` (MainAi) take over instead).
3359 let info =
3360 attach_and_resolve(Some(OperatorKind::Automate), Some(OperatorKind::MainAi)).await;
3361 assert_eq!(
3362 info.kind,
3363 OperatorKind::Automate,
3364 "explicit Some(Automate) runtime_global must outrank bp_global MainAi"
3365 );
3366 }
3367
3368 #[tokio::test]
3369 async fn none_lets_bp_global_main_ai_win() {
3370 // Runtime Global left unspecified (`None`); bp_global is MainAi.
3371 // With nothing more specific set, `bp_global` must decide.
3372 let info = attach_and_resolve(None, Some(OperatorKind::MainAi)).await;
3373 assert_eq!(
3374 info.kind,
3375 OperatorKind::MainAi,
3376 "None runtime_global must let bp_global MainAi win"
3377 );
3378 }
3379}
3380
3381/// issue #13 run_id propagation: `dispatch_attempt_with`'s `run_id` param
3382/// must land in `Ctx.meta.runtime["run_id"]` (the same slot pattern as the
3383/// pre-existing `worker_handle`), or be omitted entirely when `None`. Same
3384/// `CtxProbe` shape as `middleware::worker_binding`'s test module — an
3385/// inner `SpawnerAdapter` that snapshots the `Ctx` it was called with and
3386/// fails the spawn (only the ctx snapshot matters here).
3387#[cfg(test)]
3388mod dispatch_attempt_with_run_id_tests {
3389 use super::*;
3390 use crate::worker::adapter::{SpawnError, SpawnerAdapter};
3391 use crate::worker::Worker;
3392 use std::sync::Mutex as StdMutex;
3393
3394 struct CtxProbe {
3395 seen: Arc<StdMutex<Option<Ctx>>>,
3396 }
3397
3398 #[async_trait::async_trait]
3399 impl SpawnerAdapter for CtxProbe {
3400 async fn spawn(
3401 &self,
3402 _engine: &Engine,
3403 ctx: &Ctx,
3404 _task_id: StepId,
3405 _attempt: u32,
3406 _token: CapToken,
3407 ) -> Result<Box<dyn Worker>, SpawnError> {
3408 *self.seen.lock().unwrap() = Some(ctx.clone());
3409 Err(SpawnError::Internal("probe stop".into()))
3410 }
3411 }
3412
3413 async fn dispatch_with_probe(run_id: Option<&RunId>) -> Ctx {
3414 let engine = Engine::new(EngineCfg::default());
3415 let token = engine
3416 .attach("ut-op", Role::Operator, Duration::from_secs(30))
3417 .await
3418 .expect("attach");
3419 let tid = engine
3420 .start_task(
3421 &token,
3422 TaskSpec {
3423 agent: "probe".into(),
3424 initial_directive: "hi".into(),
3425 step_ctx: None,
3426 check_policy: None,
3427 },
3428 )
3429 .await
3430 .expect("start_task");
3431 let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
3432 let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
3433 // The probe always errors the spawn (`SpawnError::Internal`); we
3434 // only care about the `Ctx` snapshot it captured, so the dispatch
3435 // outcome itself (`Err`) is discarded.
3436 let _ = engine
3437 .dispatch_attempt_with(&token, &tid, &spawner, run_id)
3438 .await;
3439 let captured = seen.lock().unwrap().clone();
3440 captured.expect("inner ctx captured")
3441 }
3442
3443 #[tokio::test]
3444 async fn run_id_lands_in_ctx_meta_runtime_when_some() {
3445 let run_id = RunId::new();
3446 let observed = dispatch_with_probe(Some(&run_id)).await;
3447 assert_eq!(
3448 observed.meta.runtime.get("run_id").and_then(|v| v.as_str()),
3449 Some(run_id.as_str()),
3450 "ctx.meta.runtime[\"run_id\"] must carry the run_id passed to dispatch_attempt_with"
3451 );
3452 }
3453
3454 #[tokio::test]
3455 async fn run_id_key_absent_when_none() {
3456 let observed = dispatch_with_probe(None).await;
3457 assert!(
3458 !observed.meta.runtime.contains_key("run_id"),
3459 "no run_id key must be injected when dispatch_attempt_with is called with None"
3460 );
3461 }
3462}
3463
3464/// GH #21 Phase 2: `TaskSpec.step_ctx` must land in
3465/// `Ctx.meta.runtime[STEP_CTX_KEY]` — re-read from the spec on EVERY
3466/// attempt (the prep closure re-reads `task.spec.step_ctx` every call, not
3467/// caching it once at `start_task`), so a retry (attempt 2) carries it
3468/// too. Same `CtxProbe` shape as `dispatch_attempt_with_run_id_tests`.
3469#[cfg(test)]
3470mod dispatch_attempt_with_step_ctx_tests {
3471 use super::*;
3472 use crate::worker::adapter::{SpawnError, SpawnerAdapter};
3473 use crate::worker::Worker;
3474 use std::sync::Mutex as StdMutex;
3475
3476 struct CtxProbe {
3477 seen: Arc<StdMutex<Option<Ctx>>>,
3478 }
3479
3480 #[async_trait::async_trait]
3481 impl SpawnerAdapter for CtxProbe {
3482 async fn spawn(
3483 &self,
3484 _engine: &Engine,
3485 ctx: &Ctx,
3486 _task_id: StepId,
3487 _attempt: u32,
3488 _token: CapToken,
3489 ) -> Result<Box<dyn Worker>, SpawnError> {
3490 *self.seen.lock().unwrap() = Some(ctx.clone());
3491 Err(SpawnError::Internal("probe stop".into()))
3492 }
3493 }
3494
3495 #[tokio::test]
3496 async fn step_ctx_lands_in_ctx_meta_runtime_on_attempt_1_and_2() {
3497 let engine = Engine::new(EngineCfg::default());
3498 let token = engine
3499 .attach("ut-op", Role::Operator, Duration::from_secs(30))
3500 .await
3501 .expect("attach");
3502 let tid = engine
3503 .start_task(
3504 &token,
3505 TaskSpec {
3506 agent: "probe".into(),
3507 initial_directive: "hi".into(),
3508 step_ctx: Some(serde_json::json!({ "work_dir": "/step" })),
3509 check_policy: None,
3510 },
3511 )
3512 .await
3513 .expect("start_task");
3514 let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
3515 let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
3516
3517 // The probe always errors the spawn; only the ctx snapshot matters.
3518 let _ = engine
3519 .dispatch_attempt_with(&token, &tid, &spawner, None)
3520 .await;
3521 let first = seen
3522 .lock()
3523 .unwrap()
3524 .clone()
3525 .expect("attempt 1 ctx captured");
3526 assert_eq!(
3527 first.meta.runtime.get(STEP_CTX_KEY),
3528 Some(&serde_json::json!({ "work_dir": "/step" })),
3529 "attempt 1 must carry TaskSpec.step_ctx in ctx.meta.runtime[STEP_CTX_KEY]"
3530 );
3531
3532 let _ = engine
3533 .dispatch_attempt_with(&token, &tid, &spawner, None)
3534 .await;
3535 let second = seen
3536 .lock()
3537 .unwrap()
3538 .clone()
3539 .expect("attempt 2 ctx captured");
3540 assert_eq!(
3541 second.meta.runtime.get(STEP_CTX_KEY),
3542 Some(&serde_json::json!({ "work_dir": "/step" })),
3543 "attempt 2 (retry) must ALSO carry TaskSpec.step_ctx — prep re-reads the spec every attempt"
3544 );
3545 }
3546
3547 #[tokio::test]
3548 async fn step_ctx_key_absent_when_none() {
3549 let engine = Engine::new(EngineCfg::default());
3550 let token = engine
3551 .attach("ut-op", Role::Operator, Duration::from_secs(30))
3552 .await
3553 .expect("attach");
3554 let tid = engine
3555 .start_task(
3556 &token,
3557 TaskSpec {
3558 agent: "probe".into(),
3559 initial_directive: "hi".into(),
3560 step_ctx: None,
3561 check_policy: None,
3562 },
3563 )
3564 .await
3565 .expect("start_task");
3566 let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
3567 let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
3568 let _ = engine
3569 .dispatch_attempt_with(&token, &tid, &spawner, None)
3570 .await;
3571 let observed = seen.lock().unwrap().clone().expect("ctx captured");
3572 assert!(
3573 !observed.meta.runtime.contains_key(STEP_CTX_KEY),
3574 "no step_ctx key must be injected when TaskSpec.step_ctx is None"
3575 );
3576 }
3577}
3578
3579// ─── issue #18: `TaskSpec.initial_directive` `Value` pass-through ──────────
3580#[cfg(test)]
3581mod initial_directive_value_passthrough_tests {
3582 use super::*;
3583
3584 async fn seeded_engine(initial_directive: Value) -> (Engine, CapToken, StepId) {
3585 let engine = Engine::new(EngineCfg::default());
3586 let op_token = engine
3587 .attach("ut-op", Role::Operator, Duration::from_secs(30))
3588 .await
3589 .expect("attach");
3590 let task_id = engine
3591 .start_task(
3592 &op_token,
3593 TaskSpec {
3594 agent: "planner".to_string(),
3595 initial_directive,
3596 step_ctx: None,
3597 check_policy: None,
3598 },
3599 )
3600 .await
3601 .expect("start_task");
3602 (engine, op_token, task_id)
3603 }
3604
3605 /// Mint + register a `Role::Worker` token the same way
3606 /// `dispatch_attempt_with` does — `fetch_prompt` is worker-verb-gated.
3607 async fn mint_worker_token(engine: &Engine, task_id: &StepId) -> CapToken {
3608 let worker_token = engine.signer().session(
3609 format!("worker-of-{task_id}"),
3610 Role::Worker,
3611 vec!["*".into()],
3612 Duration::from_secs(600),
3613 );
3614 let fp = worker_token.fingerprint();
3615 let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
3616 engine
3617 .with_state("test.mint_worker", move |s| {
3618 s.tokens.insert(fp, record);
3619 })
3620 .await
3621 .expect("mint worker token");
3622 worker_token
3623 }
3624
3625 /// `EngineDispatcher::dispatch` no longer stringifies the evaluated
3626 /// `Step.in` value before seeding `TaskSpec.initial_directive` — an
3627 /// Object seed must round-trip through `start_task` /
3628 /// `read_task_state` byte-for-byte as the same `Value::Object`, not a
3629 /// JSON-stringified `Value::String`.
3630 #[tokio::test]
3631 async fn object_seed_passes_through_task_spec_unchanged() {
3632 let seed = serde_json::json!({"key": "value"});
3633 let (engine, token, task_id) = seeded_engine(seed.clone()).await;
3634 let state = engine
3635 .read_task_state(&token, &task_id)
3636 .await
3637 .expect("read_task_state");
3638 assert_eq!(
3639 state.spec.initial_directive, seed,
3640 "TaskSpec.initial_directive must equal the raw Object seed, not a stringified copy"
3641 );
3642 }
3643
3644 /// `Engine::fetch_prompt` returns the `Value` end-to-end (issue #18):
3645 /// an Object seed stays a `Value::Object` and is not stringified in
3646 /// the engine layer. The Worker HTTP boundary
3647 /// (`fetch_worker_payload*`) is what performs the render down to a
3648 /// JSON literal `String` for `WorkerPayload.prompt`.
3649 #[tokio::test]
3650 async fn object_seed_passes_through_fetch_prompt_as_value() {
3651 let seed = serde_json::json!({"key": "value"});
3652 let (engine, _token, task_id) = seeded_engine(seed.clone()).await;
3653 let worker_token = mint_worker_token(&engine, &task_id).await;
3654 let prompt = engine
3655 .fetch_prompt(&worker_token, &task_id)
3656 .await
3657 .expect("fetch_prompt");
3658 assert_eq!(
3659 prompt, seed,
3660 "fetch_prompt must return the raw Object Value, not a stringified copy"
3661 );
3662 }
3663
3664 /// The Worker HTTP boundary is the render point: `fetch_worker_payload*`
3665 /// coerces the stored `Value` down to `WorkerPayload.prompt: String`
3666 /// (JSON-literal shape for non-strings). Verifies the boundary render
3667 /// stays intact for an Object seed.
3668 #[tokio::test]
3669 async fn object_seed_renders_as_json_literal_at_worker_payload_boundary() {
3670 let seed = serde_json::json!({"key": "value"});
3671 let (engine, _token, task_id) = seeded_engine(seed).await;
3672 let worker_token = mint_worker_token(&engine, &task_id).await;
3673 let payload = engine
3674 .fetch_worker_payload(&worker_token, &task_id)
3675 .await
3676 .expect("fetch_worker_payload");
3677 assert_eq!(
3678 payload.prompt, r#"{"key":"value"}"#,
3679 "WorkerPayload.prompt must be the JSON literal String render of the Value seed"
3680 );
3681 }
3682
3683 /// A `String` seed is unaffected — still passes through verbatim, both
3684 /// as the `TaskSpec.initial_directive` `Value` and as the Worker
3685 /// `fetch_prompt` return (issue #18 Invariant 2).
3686 #[tokio::test]
3687 async fn string_seed_passes_through_unchanged() {
3688 let (engine, token, task_id) = seeded_engine(serde_json::json!("do the thing")).await;
3689 let state = engine
3690 .read_task_state(&token, &task_id)
3691 .await
3692 .expect("read_task_state");
3693 assert_eq!(
3694 state.spec.initial_directive,
3695 serde_json::json!("do the thing")
3696 );
3697 let worker_token = mint_worker_token(&engine, &task_id).await;
3698 let prompt = engine
3699 .fetch_prompt(&worker_token, &task_id)
3700 .await
3701 .expect("fetch_prompt");
3702 assert_eq!(prompt, serde_json::json!("do the thing"));
3703 }
3704}
3705
3706/// GH #31: `fetch_worker_payload{,_trusted}`'s size-threshold branch
3707/// between inline (`WorkerPayload.system`) and by-reference
3708/// (`WorkerPayload.system_ref`) delivery, plus the `bake_worker_system_prompt`
3709/// `agent_render_sizes` bookkeeping that feeds `agent_last_rendered_size`.
3710#[cfg(test)]
3711mod system_ref_threshold_tests {
3712 use super::*;
3713
3714 async fn seeded_engine_with_cfg(cfg: EngineCfg) -> (Engine, CapToken, StepId) {
3715 let engine = Engine::new(cfg);
3716 let op_token = engine
3717 .attach("ut-op", Role::Operator, Duration::from_secs(30))
3718 .await
3719 .expect("attach");
3720 let task_id = engine
3721 .start_task(
3722 &op_token,
3723 TaskSpec {
3724 agent: "planner".to_string(),
3725 initial_directive: serde_json::json!("do the thing"),
3726 step_ctx: None,
3727 check_policy: None,
3728 },
3729 )
3730 .await
3731 .expect("start_task");
3732 (engine, op_token, task_id)
3733 }
3734
3735 /// Same worker-token-minting fixture as
3736 /// `initial_directive_value_passthrough_tests::mint_worker_token`
3737 /// (kept local to this module — the two `mod`s do not share private
3738 /// helpers across `cfg(test)` boundaries).
3739 async fn mint_worker_token(engine: &Engine, task_id: &StepId) -> CapToken {
3740 let worker_token = engine.signer().session(
3741 format!("worker-of-{task_id}"),
3742 Role::Worker,
3743 vec!["*".into()],
3744 Duration::from_secs(600),
3745 );
3746 let fp = worker_token.fingerprint();
3747 let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
3748 engine
3749 .with_state("test.mint_worker", move |s| {
3750 s.tokens.insert(fp, record);
3751 })
3752 .await
3753 .expect("mint worker token");
3754 worker_token
3755 }
3756
3757 /// Under-threshold: `system` stays inline, `system_ref` stays `None`.
3758 #[tokio::test]
3759 async fn under_threshold_stays_inline() {
3760 let (engine, _op_token, task_id) = seeded_engine_with_cfg(EngineCfg::default()).await;
3761 let worker_token = mint_worker_token(&engine, &task_id).await;
3762 let rendered = "a short system prompt".to_string();
3763 engine
3764 .bake_worker_system_prompt(&task_id, 1, Some(rendered.clone()))
3765 .await
3766 .expect("bake");
3767 let payload = engine
3768 .fetch_worker_payload(&worker_token, &task_id)
3769 .await
3770 .expect("fetch_worker_payload");
3771 assert_eq!(payload.system, Some(rendered));
3772 assert!(payload.system_ref.is_none());
3773 }
3774
3775 /// Over-threshold: `system` is cleared and `system_ref` is populated
3776 /// with a `sha256` matching the known input string. Exercises
3777 /// `fetch_worker_payload_trusted` (the `_trusted` sibling must be
3778 /// behaviorally identical to `fetch_worker_payload`).
3779 #[tokio::test]
3780 async fn over_threshold_switches_to_system_ref_with_matching_sha256() {
3781 let mut cfg = EngineCfg::default();
3782 cfg.system_ref.threshold_bytes = 16;
3783 cfg.system_ref.mode = crate::types::SystemRefMode::File;
3784 cfg.system_ref.store_dir =
3785 std::env::temp_dir().join(format!("mse-system-ref-test-{}", crate::types::now_unix()));
3786 let (engine, _op_token, task_id) = seeded_engine_with_cfg(cfg).await;
3787 let rendered =
3788 "this system prompt is deliberately longer than the 16 byte threshold".to_string();
3789 engine
3790 .bake_worker_system_prompt(&task_id, 1, Some(rendered.clone()))
3791 .await
3792 .expect("bake");
3793 let payload = engine
3794 .fetch_worker_payload_trusted(&task_id)
3795 .await
3796 .expect("fetch_worker_payload_trusted");
3797 assert!(
3798 payload.system.is_none(),
3799 "over-threshold response must not also inline `system`"
3800 );
3801 let system_ref = payload
3802 .system_ref
3803 .expect("over-threshold response must populate system_ref");
3804 assert_eq!(system_ref.size_bytes, rendered.len() as u64);
3805 assert_eq!(system_ref.mode, crate::types::SystemRefMode::File);
3806 use sha2::Digest;
3807 let expected_sha256 = hex::encode(sha2::Sha256::digest(rendered.as_bytes()));
3808 assert_eq!(system_ref.sha256, expected_sha256);
3809 assert!(system_ref.uri.starts_with("file://"));
3810 let written = tokio::fs::read_to_string(system_ref.uri.trim_start_matches("file://"))
3811 .await
3812 .expect("File mode must have written the referenced path");
3813 assert_eq!(written, rendered);
3814 }
3815
3816 /// `Http` mode never writes a file — `system_ref.uri` is the bare path
3817 /// the engine can construct on its own, scheme/host-free.
3818 #[tokio::test]
3819 async fn over_threshold_http_mode_constructs_path_only_uri() {
3820 let mut cfg = EngineCfg::default();
3821 cfg.system_ref.threshold_bytes = 16;
3822 cfg.system_ref.mode = crate::types::SystemRefMode::Http;
3823 let (engine, _op_token, task_id) = seeded_engine_with_cfg(cfg).await;
3824 let worker_token = mint_worker_token(&engine, &task_id).await;
3825 let rendered =
3826 "this system prompt is deliberately longer than the 16 byte threshold".to_string();
3827 engine
3828 .bake_worker_system_prompt(&task_id, 1, Some(rendered))
3829 .await
3830 .expect("bake");
3831 let payload = engine
3832 .fetch_worker_payload(&worker_token, &task_id)
3833 .await
3834 .expect("fetch_worker_payload");
3835 let system_ref = payload.system_ref.expect("system_ref must be populated");
3836 assert_eq!(system_ref.mode, crate::types::SystemRefMode::Http);
3837 assert_eq!(
3838 system_ref.uri,
3839 format!("/v1/worker/prompt/system?task_id={task_id}&attempt=1")
3840 );
3841 }
3842
3843 /// `bake_worker_system_prompt` records the render size keyed by agent
3844 /// name (last-write-wins), readable via `agent_last_rendered_size`.
3845 #[tokio::test]
3846 async fn bake_records_agent_render_size_last_write_wins() {
3847 let (engine, _op_token, task_id) = seeded_engine_with_cfg(EngineCfg::default()).await;
3848 assert_eq!(engine.agent_last_rendered_size("planner").await, None);
3849 engine
3850 .bake_worker_system_prompt(&task_id, 1, Some("a".repeat(10)))
3851 .await
3852 .expect("bake 1");
3853 assert_eq!(engine.agent_last_rendered_size("planner").await, Some(10));
3854 engine
3855 .bake_worker_system_prompt(&task_id, 2, Some("b".repeat(20)))
3856 .await
3857 .expect("bake 2");
3858 assert_eq!(
3859 engine.agent_last_rendered_size("planner").await,
3860 Some(20),
3861 "most-recently-observed size wins, not the largest"
3862 );
3863 }
3864}
3865
3866/// subtask-4 / ST2 rework: `submit_output` / `submit_worker_result_trusted`'s
3867/// submit-time projection sink (`Engine::materialize_final_submission`) —
3868/// the Data-plane `OutputStore` dual-write plus the
3869/// `FileProjectionAdapter`-backed file materialize, both fail-open. See
3870/// the subtask-4 Tests this module covers inline on each test.
3871#[cfg(test)]
3872mod submit_time_projection_sink_tests {
3873 use super::*;
3874 use crate::core::agent_context::AgentContextView;
3875 use crate::store::output::{ContentRef, InMemoryOutputStore, OutputEvent};
3876
3877 /// Starts a task under `agent`, returning `(engine, op_token, task_id,
3878 /// worker_token)` — same helper shape as the sibling test modules
3879 /// above (`initial_directive_value_passthrough_tests::seeded_engine` /
3880 /// `mint_worker_token`), duplicated locally per this file's
3881 /// established per-module convention.
3882 async fn seeded_task(agent: &str) -> (Engine, CapToken, StepId, CapToken) {
3883 let engine = Engine::new(EngineCfg::default());
3884 let op_token = engine
3885 .attach("ut-op", Role::Operator, Duration::from_secs(30))
3886 .await
3887 .expect("attach");
3888 let task_id = engine
3889 .start_task(
3890 &op_token,
3891 TaskSpec {
3892 agent: agent.to_string(),
3893 initial_directive: Value::String("go".into()),
3894 step_ctx: None,
3895 check_policy: None,
3896 },
3897 )
3898 .await
3899 .expect("start_task");
3900 let worker_token = engine.signer().session(
3901 format!("worker-of-{task_id}"),
3902 Role::Worker,
3903 vec!["*".into()],
3904 Duration::from_secs(600),
3905 );
3906 let fp = worker_token.fingerprint();
3907 let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
3908 engine
3909 .with_state("test.mint_worker", move |s| {
3910 s.tokens.insert(fp, record);
3911 })
3912 .await
3913 .expect("mint worker token");
3914 (engine, op_token, task_id, worker_token)
3915 }
3916
3917 /// Sibling of [`seeded_task`] that lets a caller pin the engine's
3918 /// `EngineCfg.check_policy` before the engine is constructed — used
3919 /// by the `check_policy_*` regression tests below to exercise the
3920 /// three [`crate::core::config::CheckPolicy`] modes without touching
3921 /// the shared `seeded_task` helper (which every unrelated sink test
3922 /// depends on).
3923 async fn seeded_task_with_policy(
3924 agent: &str,
3925 policy: crate::core::config::CheckPolicy,
3926 ) -> (Engine, CapToken, StepId, CapToken) {
3927 let cfg = EngineCfg {
3928 check_policy: policy,
3929 ..EngineCfg::default()
3930 };
3931 let engine = Engine::new(cfg);
3932 let op_token = engine
3933 .attach("ut-op", Role::Operator, Duration::from_secs(30))
3934 .await
3935 .expect("attach");
3936 let task_id = engine
3937 .start_task(
3938 &op_token,
3939 TaskSpec {
3940 agent: agent.to_string(),
3941 initial_directive: Value::String("go".into()),
3942 step_ctx: None,
3943 check_policy: None,
3944 },
3945 )
3946 .await
3947 .expect("start_task");
3948 let worker_token = engine.signer().session(
3949 format!("worker-of-{task_id}"),
3950 Role::Worker,
3951 vec!["*".into()],
3952 Duration::from_secs(600),
3953 );
3954 let fp = worker_token.fingerprint();
3955 let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
3956 engine
3957 .with_state("test.mint_worker", move |s| {
3958 s.tokens.insert(fp, record);
3959 })
3960 .await
3961 .expect("mint worker token");
3962 (engine, op_token, task_id, worker_token)
3963 }
3964
3965 /// Seeds `EngineState.agent_ctx[(task_id, attempt)].view` directly —
3966 /// the same snapshot `AgentContextMiddleware` writes at spawn time
3967 /// (see its module doc), stood up here without the full spawner
3968 /// stack so these tests can exercise `submit_output` in isolation.
3969 async fn seed_agent_context(engine: &Engine, task_id: &StepId, attempt: u32, work_dir: &str) {
3970 let task_id = task_id.clone();
3971 let work_dir = work_dir.to_string();
3972 engine
3973 .with_state("test.seed_agent_context", move |s| {
3974 s.agent_ctx.insert(
3975 (task_id, attempt),
3976 crate::core::state::AgentCtxEntry {
3977 view: AgentContextView {
3978 work_dir: Some(work_dir),
3979 ..Default::default()
3980 },
3981 policy: Default::default(),
3982 },
3983 );
3984 })
3985 .await
3986 .expect("seed agent_ctx");
3987 }
3988
3989 /// GH #27 (follow-up to #23): seeds `EngineState.agent_ctx` with an
3990 /// arbitrary `work_dir` / `project_root` pair (either may be `None`),
3991 /// unlike [`seed_agent_context`] (which only ever sets `work_dir`) —
3992 /// lets these tests exercise `ProjectionPlacement::resolve_root`'s
3993 /// fallback in both directions.
3994 async fn seed_agent_context_roots(
3995 engine: &Engine,
3996 task_id: &StepId,
3997 attempt: u32,
3998 work_dir: Option<&str>,
3999 project_root: Option<&str>,
4000 ) {
4001 let task_id = task_id.clone();
4002 let work_dir = work_dir.map(str::to_string);
4003 let project_root = project_root.map(str::to_string);
4004 engine
4005 .with_state("test.seed_agent_context_roots", move |s| {
4006 s.agent_ctx.insert(
4007 (task_id, attempt),
4008 crate::core::state::AgentCtxEntry {
4009 view: AgentContextView {
4010 work_dir,
4011 project_root,
4012 ..Default::default()
4013 },
4014 policy: Default::default(),
4015 },
4016 );
4017 })
4018 .await
4019 .expect("seed agent_ctx");
4020 }
4021
4022 /// GH #27 (follow-up to #23): seeds `EngineState.projection_placements`
4023 /// directly — the same snapshot `EngineDispatcher::dispatch` stashes
4024 /// at dispatch time (mirroring [`seed_step_naming`]'s contract) — so
4025 /// these tests can exercise a declared `ProjectionPlacement` without
4026 /// driving a real `Compiler::compile`.
4027 async fn seed_projection_placement(
4028 engine: &Engine,
4029 task_id: &StepId,
4030 placement: crate::core::projection_placement::ProjectionPlacement,
4031 ) {
4032 let task_id = task_id.clone();
4033 let placement = Arc::new(placement);
4034 engine
4035 .with_state("test.seed_projection_placement", move |s| {
4036 s.projection_placements.insert(task_id, placement);
4037 })
4038 .await
4039 .expect("seed projection_placements");
4040 }
4041
4042 /// GH #23 subtask-2: builds a fixture
4043 /// [`crate::core::step_naming::StepNaming`] table declaring `producer`
4044 /// → `canonical` (`AgentMeta.projection_name`), then seeds it into
4045 /// `EngineState.step_namings` for `task_id` — the same snapshot
4046 /// `EngineDispatcher::dispatch` stashes at dispatch time
4047 /// (`blueprint.rs`'s "construct once, read many" contract), stood up
4048 /// here without the full Blueprint-compile path so these tests can
4049 /// exercise the canonical-sink resolution in isolation.
4050 async fn seed_step_naming(engine: &Engine, task_id: &StepId, producer: &str, canonical: &str) {
4051 use crate::blueprint::{
4052 current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
4053 CompilerHints, CompilerStrategy,
4054 };
4055 use crate::core::step_naming::StepNaming;
4056 use mlua_flow_ir::{Expr, Node};
4057
4058 let flow = Node::Step {
4059 ref_: producer.to_string(),
4060 in_: Expr::Path {
4061 at: "$.in".parse().expect("literal test path: $.in"),
4062 },
4063 out: Expr::Path {
4064 at: format!("$.{producer}_out")
4065 .parse()
4066 .expect("literal test path"),
4067 },
4068 };
4069 let bp = Blueprint {
4070 schema_version: current_schema_version(),
4071 id: "sink-canonical-ut".into(),
4072 flow,
4073 agents: vec![AgentDef {
4074 name: producer.to_string(),
4075 kind: AgentKind::RustFn,
4076 spec: serde_json::json!({ "fn_id": producer }),
4077 profile: None,
4078 meta: Some(AgentMeta {
4079 projection_name: Some(canonical.to_string()),
4080 ..Default::default()
4081 }),
4082 runner: None,
4083 runner_ref: None,
4084 verdict: None,
4085 }],
4086 operators: vec![],
4087 metas: vec![],
4088 hints: CompilerHints::default(),
4089 strategy: CompilerStrategy::default(),
4090 metadata: BlueprintMetadata::default(),
4091 spawner_hints: Default::default(),
4092 default_agent_kind: AgentKind::Operator,
4093 default_operator_kind: None,
4094 default_init_ctx: None,
4095 default_agent_ctx: None,
4096 default_context_policy: None,
4097 projection_placement: None,
4098 audits: vec![],
4099 degradation_policy: None,
4100 runners: vec![],
4101 default_runner: None,
4102 check_policy: None,
4103 blueprint_ref_includes: Vec::new(),
4104 };
4105 let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
4106 assert!(warnings.is_empty(), "single-step fixture has no collisions");
4107 let naming = Arc::new(naming);
4108 let task_id = task_id.clone();
4109 engine
4110 .with_state("test.seed_step_naming", move |s| {
4111 s.step_namings.insert(task_id, naming);
4112 })
4113 .await
4114 .expect("seed step_namings");
4115 }
4116
4117 fn final_event(value: Value, ok: bool) -> crate::worker::output::OutputEvent {
4118 crate::worker::output::OutputEvent::Final {
4119 content: crate::worker::output::ContentRef::Inline { value },
4120 ok,
4121 }
4122 }
4123
4124 /// Subtask 4 Test #2: `submit_output`'s `Final` writes
4125 /// `<root>/workspace/tasks/<task_id>/ctx/<agent>.md`, content matching
4126 /// the submitted value.
4127 #[tokio::test]
4128 async fn submit_output_final_materializes_file_when_work_dir_resolved() {
4129 let dir = tempfile::TempDir::new().unwrap();
4130 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
4131 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4132
4133 engine
4134 .submit_output(
4135 &worker_token,
4136 &task_id,
4137 1,
4138 final_event(serde_json::json!({"plan": "do it"}), true),
4139 )
4140 .await
4141 .expect("submit_output");
4142
4143 let expected_file = dir
4144 .path()
4145 .join("workspace/tasks")
4146 .join(task_id.as_str())
4147 .join("ctx/planner.md");
4148 assert!(
4149 expected_file.exists(),
4150 "materialized submission file missing at {expected_file:?}"
4151 );
4152 let body = std::fs::read_to_string(expected_file).unwrap();
4153 assert!(body.contains(r#""plan": "do it""#), "body: {body}");
4154 }
4155
4156 /// Subtask 4 Test #3: `work_dir` unresolved (no `agent_ctx`
4157 /// snapshot for this `(task_id, attempt)`) — submit still succeeds,
4158 /// fail-open, no file.
4159 #[tokio::test]
4160 async fn submit_output_final_skips_file_when_root_unresolved() {
4161 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
4162 // No seed_agent_context call — root is unresolved.
4163
4164 let result = engine
4165 .submit_output(
4166 &worker_token,
4167 &task_id,
4168 1,
4169 final_event(serde_json::json!("hi"), true),
4170 )
4171 .await;
4172 assert!(
4173 result.is_ok(),
4174 "submit must succeed even with no resolvable root (fail-open, Invariant 1)"
4175 );
4176 }
4177
4178 /// Regression for the check_policy cascade: the default
4179 /// [`crate::core::config::CheckPolicy::Warn`] preserves the
4180 /// pre-`CheckPolicy` fail-open semantics — a submit whose root is
4181 /// unresolved still succeeds. Byte-compat with
4182 /// `submit_output_final_skips_file_when_root_unresolved`; this test
4183 /// pins the mode explicitly so a future default change to
4184 /// `Strict` (silent breakage) is caught here.
4185 #[tokio::test]
4186 async fn submit_output_final_check_policy_warn_preserves_fail_open() {
4187 let (engine, _op, task_id, worker_token) =
4188 seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Warn).await;
4189
4190 let result = engine
4191 .submit_output(
4192 &worker_token,
4193 &task_id,
4194 1,
4195 final_event(serde_json::json!("hi"), true),
4196 )
4197 .await;
4198 assert!(
4199 result.is_ok(),
4200 "Warn mode preserves fail-open: submit must succeed when root unresolved"
4201 );
4202 }
4203
4204 /// Regression for the check_policy cascade:
4205 /// [`crate::core::config::CheckPolicy::Strict`] surfaces the "no
4206 /// work_dir/project_root resolved" fail-open condition as an
4207 /// [`EngineError::CheckPolicyStrict`], letting a caller who has
4208 /// opted in fail fast instead of proceeding with a partially-
4209 /// realized submission. The error's `context` identifies the call
4210 /// site (`"file materialize"`), and `message` preserves the
4211 /// pre-`CheckPolicy` warn literal verbatim (log-parse compat).
4212 #[tokio::test]
4213 async fn submit_output_final_check_policy_strict_surfaces_error_when_root_unresolved() {
4214 let (engine, _op, task_id, worker_token) =
4215 seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Strict).await;
4216
4217 let err = engine
4218 .submit_output(
4219 &worker_token,
4220 &task_id,
4221 1,
4222 final_event(serde_json::json!("hi"), true),
4223 )
4224 .await
4225 .expect_err("Strict mode must return an error when root unresolved");
4226 match err {
4227 EngineError::CheckPolicyStrict { context, message } => {
4228 assert!(
4229 context.contains("file materialize"),
4230 "context must identify the call site: {context}"
4231 );
4232 assert!(
4233 message.contains("no work_dir/project_root resolved"),
4234 "message must preserve the warn-log literal for log-parse compat: {message}"
4235 );
4236 }
4237 other => panic!(
4238 "expected EngineError::CheckPolicyStrict, got a different variant: {other:?}"
4239 ),
4240 }
4241 }
4242
4243 /// Regression for the check_policy cascade:
4244 /// [`crate::core::config::CheckPolicy::Silent`] returns `Ok(())` (
4245 /// like `Warn`) without surfacing an error. The log-suppression side
4246 /// of `Silent` (no `tracing::warn!`) is enforced at the call site
4247 /// via the `if !matches!(policy, Silent) { warn!(...) }` guard —
4248 /// verifying tracing output shape here would couple the test to a
4249 /// subscriber setup, so the assertion is limited to the error-
4250 /// return semantics (matches the helper unit tests in
4251 /// `check_policy_helper_tests`).
4252 #[tokio::test]
4253 async fn submit_output_final_check_policy_silent_returns_ok_when_root_unresolved() {
4254 let (engine, _op, task_id, worker_token) =
4255 seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Silent).await;
4256
4257 let result = engine
4258 .submit_output(
4259 &worker_token,
4260 &task_id,
4261 1,
4262 final_event(serde_json::json!("hi"), true),
4263 )
4264 .await;
4265 assert!(
4266 result.is_ok(),
4267 "Silent mode returns Ok(()) at the error surface: submit must succeed"
4268 );
4269 }
4270
4271 /// Subtask 4 Test #4 (file half): re-submitting under the same
4272 /// `(task_id, agent)` overwrites the materialized file with the
4273 /// latest value.
4274 #[tokio::test]
4275 async fn resubmit_overwrites_materialized_file_with_latest() {
4276 let dir = tempfile::TempDir::new().unwrap();
4277 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
4278 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4279
4280 engine
4281 .submit_output(
4282 &worker_token,
4283 &task_id,
4284 1,
4285 final_event(serde_json::json!("first"), true),
4286 )
4287 .await
4288 .expect("first submit");
4289 engine
4290 .submit_output(
4291 &worker_token,
4292 &task_id,
4293 1,
4294 final_event(serde_json::json!("second"), true),
4295 )
4296 .await
4297 .expect("second submit");
4298
4299 let expected_file = dir
4300 .path()
4301 .join("workspace/tasks")
4302 .join(task_id.as_str())
4303 .join("ctx/planner.md");
4304 let body = std::fs::read_to_string(expected_file).unwrap();
4305 assert!(body.contains("second"), "body must reflect latest: {body}");
4306 assert!(
4307 !body.contains("first"),
4308 "body must not carry the stale value: {body}"
4309 );
4310 }
4311
4312 /// GH #27 (follow-up to #23): the byte-compat default
4313 /// `ProjectionPlacement` (`root_preference = WorkDir`) falls back to
4314 /// `project_root` when `work_dir` is absent — the same fallback
4315 /// [`crate::core::projection_placement::ProjectionPlacement::resolve_root`]
4316 /// now performs for every one of the "3 path" call sites, this one
4317 /// exercised at the submit-sink layer.
4318 #[tokio::test]
4319 async fn submit_output_final_falls_back_to_project_root_when_work_dir_absent() {
4320 let dir = tempfile::TempDir::new().unwrap();
4321 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
4322 seed_agent_context_roots(
4323 &engine,
4324 &task_id,
4325 1,
4326 None,
4327 Some(&dir.path().to_string_lossy()),
4328 )
4329 .await;
4330
4331 engine
4332 .submit_output(
4333 &worker_token,
4334 &task_id,
4335 1,
4336 final_event(serde_json::json!({"plan": "via project_root"}), true),
4337 )
4338 .await
4339 .expect("submit_output");
4340
4341 let expected_file = dir
4342 .path()
4343 .join("workspace/tasks")
4344 .join(task_id.as_str())
4345 .join("ctx/planner.md");
4346 assert!(
4347 expected_file.exists(),
4348 "materialized submission file missing at {expected_file:?} \
4349 (work_dir absent must fall back to project_root)"
4350 );
4351 }
4352
4353 /// GH #27 (follow-up to #23): a declared `ProjectionPlacement`
4354 /// (`root_preference = ProjectRoot`, custom `dir_template`) changes
4355 /// BOTH which root is preferred (project_root wins even though
4356 /// work_dir is also present) AND the target directory layout — proof
4357 /// the submit sink consults the snapshotted resolver rather than a
4358 /// hardcoded layout.
4359 #[tokio::test]
4360 async fn submit_output_final_uses_declared_projection_placement() {
4361 let work_dir = tempfile::TempDir::new().unwrap();
4362 let project_root = tempfile::TempDir::new().unwrap();
4363 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
4364 seed_agent_context_roots(
4365 &engine,
4366 &task_id,
4367 1,
4368 Some(&work_dir.path().to_string_lossy()),
4369 Some(&project_root.path().to_string_lossy()),
4370 )
4371 .await;
4372 seed_projection_placement(
4373 &engine,
4374 &task_id,
4375 crate::core::projection_placement::ProjectionPlacement {
4376 root_preference: crate::core::projection_placement::RootPreference::ProjectRoot,
4377 dir_template: "custom/{task_id}/out".to_string(),
4378 },
4379 )
4380 .await;
4381
4382 engine
4383 .submit_output(
4384 &worker_token,
4385 &task_id,
4386 1,
4387 final_event(serde_json::json!({"plan": "via custom placement"}), true),
4388 )
4389 .await
4390 .expect("submit_output");
4391
4392 let expected_file = project_root
4393 .path()
4394 .join("custom")
4395 .join(task_id.as_str())
4396 .join("out/planner.md");
4397 assert!(
4398 expected_file.exists(),
4399 "materialized submission file missing at custom placement target {expected_file:?}"
4400 );
4401 let unexpected_file = work_dir
4402 .path()
4403 .join("workspace/tasks")
4404 .join(task_id.as_str())
4405 .join("ctx/planner.md");
4406 assert!(
4407 !unexpected_file.exists(),
4408 "declared root_preference=ProjectRoot must not fall back to work_dir: {unexpected_file:?}"
4409 );
4410 }
4411
4412 /// Subtask 4 Invariant 3 / crux requirement #3: when
4413 /// [`Engine::set_output_store`] wires a Data-plane [`crate::store::output::OutputStore`],
4414 /// `submit_output`'s `Final` dual-writes into it under
4415 /// `producer_agent = TaskState.spec.agent` — the store becomes
4416 /// queryable via `get_latest_by_name`, independent of whether a root
4417 /// resolved for the file half.
4418 #[tokio::test]
4419 async fn submit_output_final_dual_writes_into_configured_output_store() {
4420 let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
4421 let data_store: Arc<dyn crate::store::output::OutputStore> =
4422 Arc::new(InMemoryOutputStore::new());
4423 engine.set_output_store(data_store.clone());
4424
4425 engine
4426 .submit_output(
4427 &worker_token,
4428 &task_id,
4429 1,
4430 final_event(serde_json::json!({"verdict": "pass"}), true),
4431 )
4432 .await
4433 .expect("submit_output");
4434
4435 let record = data_store
4436 .get_latest_by_name("reviewer")
4437 .await
4438 .expect("dual-written record");
4439 match record.event {
4440 OutputEvent::Final { content, ok } => {
4441 assert!(ok);
4442 match content {
4443 ContentRef::Inline { value } => {
4444 assert_eq!(value, serde_json::json!({"verdict": "pass"}));
4445 }
4446 other => panic!("expected Inline content, got {other:?}"),
4447 }
4448 }
4449 other => panic!("expected Final event, got {other:?}"),
4450 }
4451 }
4452
4453 /// GH #34 subtask-3 gap fix: an `Artifact` event submitted via
4454 /// `submit_output` dual-writes into a wired Data-plane `OutputStore`
4455 /// under its OWN `name`, verbatim — mirrors
4456 /// `submit_output_final_dual_writes_into_configured_output_store`
4457 /// above, but for the `Artifact` variant.
4458 #[tokio::test]
4459 async fn submit_output_artifact_dual_writes_into_configured_output_store() {
4460 let (engine, _op, task_id, worker_token) = seeded_task("echo").await;
4461 let data_store: Arc<dyn crate::store::output::OutputStore> =
4462 Arc::new(InMemoryOutputStore::new());
4463 engine.set_output_store(data_store.clone());
4464
4465 engine
4466 .submit_output(
4467 &worker_token,
4468 &task_id,
4469 1,
4470 OutputEvent::Artifact {
4471 name: "audit:echo".to_string(),
4472 content: ContentRef::Inline {
4473 value: serde_json::json!({"finding": "clean"}),
4474 },
4475 },
4476 )
4477 .await
4478 .expect("submit_output");
4479
4480 let record = data_store
4481 .get_latest_by_name("audit:echo")
4482 .await
4483 .expect("dual-written artifact record");
4484 match record.event {
4485 OutputEvent::Artifact { name, content } => {
4486 assert_eq!(name, "audit:echo");
4487 match content {
4488 ContentRef::Inline { value } => {
4489 assert_eq!(value, serde_json::json!({"finding": "clean"}));
4490 }
4491 other => panic!("expected Inline content, got {other:?}"),
4492 }
4493 }
4494 other => panic!("expected Artifact event, got {other:?}"),
4495 }
4496 // The `Artifact` dual-write must never collide with / overwrite
4497 // the producing step's own `Final` name — `submit_output` never
4498 // materialized a `Final` here, so `"echo"` must stay unresolved.
4499 assert!(
4500 data_store.get_latest_by_name("echo").await.is_err(),
4501 "artifact write must not fabricate a record under the raw producer_agent name"
4502 );
4503 }
4504
4505 /// Invariant 1 (fail-open) for `Artifact`, mirroring
4506 /// `submit_output_final_skips_file_when_root_unresolved`'s Final-side
4507 /// coverage: no `OutputStore` wired at all — submit still succeeds.
4508 #[tokio::test]
4509 async fn submit_output_artifact_is_fail_open_when_no_output_store_configured() {
4510 let (engine, _op, task_id, worker_token) = seeded_task("echo").await;
4511
4512 let result = engine
4513 .submit_output(
4514 &worker_token,
4515 &task_id,
4516 1,
4517 OutputEvent::Artifact {
4518 name: "audit:echo".to_string(),
4519 content: ContentRef::Inline {
4520 value: serde_json::json!("finding"),
4521 },
4522 },
4523 )
4524 .await;
4525 assert!(
4526 result.is_ok(),
4527 "submit must succeed even with no OutputStore wired (fail-open, Invariant 1)"
4528 );
4529 }
4530
4531 /// `submit_worker_result_trusted` (the `/v1/worker/submit` short-handle
4532 /// path) triggers the exact same sink as `submit_output` — parity
4533 /// across both worker-submit entry points.
4534 #[tokio::test]
4535 async fn submit_worker_result_trusted_also_triggers_projection_sink() {
4536 let dir = tempfile::TempDir::new().unwrap();
4537 let (engine, _op, task_id, _worker_token) = seeded_task("planner").await;
4538 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4539 let data_store: Arc<dyn crate::store::output::OutputStore> =
4540 Arc::new(InMemoryOutputStore::new());
4541 engine.set_output_store(data_store.clone());
4542
4543 engine
4544 .submit_worker_result_trusted(
4545 &task_id,
4546 1,
4547 serde_json::json!("trusted-value"),
4548 SubmitOutcome::Pass,
4549 )
4550 .await
4551 .expect("submit_worker_result_trusted");
4552
4553 let expected_file = dir
4554 .path()
4555 .join("workspace/tasks")
4556 .join(task_id.as_str())
4557 .join("ctx/planner.md");
4558 assert!(expected_file.exists());
4559 let record = data_store
4560 .get_latest_by_name("planner")
4561 .await
4562 .expect("dual-written record");
4563 assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
4564 }
4565
4566 /// GH #23 subtask-2 (canonical sink): a declared `projection_name`
4567 /// (`AgentMeta.projection_name`, surfaced via `StepNaming`) redirects
4568 /// `submit_output`'s Final canonical sink — both the Data-plane
4569 /// dual-write name and the materialized file stem resolve to the
4570 /// canonical name, not the raw `producer_agent`.
4571 #[tokio::test]
4572 async fn submit_output_final_uses_canonical_name_when_step_naming_declares_one() {
4573 let dir = tempfile::TempDir::new().unwrap();
4574 let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
4575 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4576 seed_step_naming(&engine, &task_id, "reviewer", "verdict-final").await;
4577 let data_store: Arc<dyn crate::store::output::OutputStore> =
4578 Arc::new(InMemoryOutputStore::new());
4579 engine.set_output_store(data_store.clone());
4580
4581 engine
4582 .submit_output(
4583 &worker_token,
4584 &task_id,
4585 1,
4586 final_event(serde_json::json!({"verdict": "pass"}), true),
4587 )
4588 .await
4589 .expect("submit_output");
4590
4591 let record = data_store
4592 .get_latest_by_name("verdict-final")
4593 .await
4594 .expect("dual-written record under canonical name");
4595 assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
4596 assert!(
4597 data_store.get_latest_by_name("reviewer").await.is_err(),
4598 "raw producer_agent name must not be written once canonical resolves"
4599 );
4600
4601 let expected_file = dir
4602 .path()
4603 .join("workspace/tasks")
4604 .join(task_id.as_str())
4605 .join("ctx/verdict-final.md");
4606 assert!(
4607 expected_file.exists(),
4608 "materialized file stem must be canonical at {expected_file:?}"
4609 );
4610 }
4611
4612 /// GH #23 subtask-2: no `StepNaming` table snapshotted for this
4613 /// `task_id` (the pre-GH-#23 / no-`with_step_naming` path) is a
4614 /// defensive fail-open — the canonical sink falls back to the raw
4615 /// `producer_agent`, byte-identical to
4616 /// `submit_output_final_dual_writes_into_configured_output_store`
4617 /// above (which never calls `seed_step_naming`).
4618 #[tokio::test]
4619 async fn submit_output_final_falls_back_to_producer_agent_when_no_step_naming_table() {
4620 let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
4621 let data_store: Arc<dyn crate::store::output::OutputStore> =
4622 Arc::new(InMemoryOutputStore::new());
4623 engine.set_output_store(data_store.clone());
4624
4625 engine
4626 .submit_output(
4627 &worker_token,
4628 &task_id,
4629 1,
4630 final_event(serde_json::json!({"verdict": "pass"}), true),
4631 )
4632 .await
4633 .expect("submit_output");
4634
4635 let record = data_store
4636 .get_latest_by_name("reviewer")
4637 .await
4638 .expect("fail-open dual-write under raw producer_agent name");
4639 assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
4640 }
4641
4642 /// GH #23 subtask-2 (Layer 2): `OutputStore::get_latest_by_name_in_run`
4643 /// resolves the value `submit_output` dual-wrote for this exact
4644 /// `(task_id, attempt)` run, independent of `get_latest_by_name`'s
4645 /// cross-Run race (two Runs sharing a producer name never bleed into
4646 /// each other through the Run-scoped accessor).
4647 #[tokio::test]
4648 async fn submit_output_final_is_resolvable_via_run_scoped_lookup() {
4649 let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
4650 let data_store: Arc<dyn crate::store::output::OutputStore> =
4651 Arc::new(InMemoryOutputStore::new());
4652 engine.set_output_store(data_store.clone());
4653
4654 engine
4655 .submit_output(
4656 &worker_token,
4657 &task_id,
4658 1,
4659 final_event(serde_json::json!({"verdict": "pass"}), true),
4660 )
4661 .await
4662 .expect("submit_output");
4663
4664 let record = data_store
4665 .get_latest_by_name_in_run(task_id.as_str(), 1, "reviewer")
4666 .await
4667 .expect("run-scoped lookup resolves the dual-written record");
4668 assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
4669
4670 // A different attempt of the same task must not resolve — the
4671 // Run-scoped lookup does not fall back across attempts.
4672 assert!(
4673 data_store
4674 .get_latest_by_name_in_run(task_id.as_str(), 2, "reviewer")
4675 .await
4676 .is_err(),
4677 "a different attempt must not resolve the same-named record"
4678 );
4679 }
4680
4681 // ─── staged part file materialize ───
4682
4683 /// Staging a part with a resolved `work_dir` writes
4684 /// `<work_dir>/workspace/tasks/<task_id>/ctx/<name>` with the part's
4685 /// content RAW (no front matter / fenced wrapper).
4686 #[tokio::test]
4687 async fn stage_artifact_materializes_part_file_when_work_dir_resolved() {
4688 let dir = tempfile::TempDir::new().unwrap();
4689 let (engine, _op, task_id, _worker_token) = seeded_task("planner").await;
4690 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4691
4692 engine
4693 .stage_worker_artifact_trusted(
4694 &task_id,
4695 1,
4696 "plan.md".to_string(),
4697 serde_json::json!("# Plan\n\nstep one\n"),
4698 )
4699 .await
4700 .expect("stage artifact");
4701
4702 let expected_file = dir
4703 .path()
4704 .join("workspace/tasks")
4705 .join(task_id.as_str())
4706 .join("ctx/plan.md");
4707 assert!(
4708 expected_file.exists(),
4709 "materialized part file missing at {expected_file:?}"
4710 );
4711 let body = std::fs::read_to_string(expected_file).unwrap();
4712 // Raw — no YAML front matter / fenced-json wrapper.
4713 assert_eq!(body, "# Plan\n\nstep one\n");
4714 }
4715
4716 /// No resolvable root + `Warn` — staging still
4717 /// succeeds (fail-open), and no part file is written.
4718 #[tokio::test]
4719 async fn stage_artifact_check_policy_warn_skips_part_file_when_root_unresolved() {
4720 let dir = tempfile::TempDir::new().unwrap();
4721 let (engine, _op, task_id, _worker_token) =
4722 seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Warn).await;
4723 // No seed_agent_context — root unresolved.
4724
4725 let result = engine
4726 .stage_worker_artifact_trusted(
4727 &task_id,
4728 1,
4729 "plan.md".to_string(),
4730 serde_json::json!("x"),
4731 )
4732 .await;
4733 assert!(
4734 result.is_ok(),
4735 "Warn mode preserves fail-open: stage must succeed when root unresolved"
4736 );
4737 assert!(
4738 !dir.path().join("workspace").exists(),
4739 "no part file may be materialized when root is unresolved"
4740 );
4741 }
4742
4743 /// No resolvable root + `Strict` — staging surfaces
4744 /// the fail-open condition as an [`EngineError::CheckPolicyStrict`],
4745 /// its message identifying the "part file materialize" call site.
4746 #[tokio::test]
4747 async fn stage_artifact_check_policy_strict_surfaces_error_when_root_unresolved() {
4748 let (engine, _op, task_id, _worker_token) =
4749 seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Strict).await;
4750
4751 let err = engine
4752 .stage_worker_artifact_trusted(
4753 &task_id,
4754 1,
4755 "plan.md".to_string(),
4756 serde_json::json!("x"),
4757 )
4758 .await
4759 .expect_err("Strict mode must return an error when root unresolved");
4760 match err {
4761 EngineError::CheckPolicyStrict { context, message } => {
4762 assert!(
4763 context.contains("part file materialize"),
4764 "context must identify the call site: {context}"
4765 );
4766 assert!(
4767 message.contains("part file materialize"),
4768 "message must identify the part-file sink: {message}"
4769 );
4770 assert!(
4771 message.contains("no work_dir/project_root resolved"),
4772 "message must preserve the warn-log literal: {message}"
4773 );
4774 }
4775 other => panic!(
4776 "expected EngineError::CheckPolicyStrict, got a different variant: {other:?}"
4777 ),
4778 }
4779 }
4780
4781 /// A path-traversal `name` (`../evil.md`) with a
4782 /// resolved root — the name guard fails the write, but fail-open keeps
4783 /// the stage succeeding, and nothing is written outside the ctx dir.
4784 #[tokio::test]
4785 async fn stage_artifact_traversal_name_is_fail_open_and_writes_nothing() {
4786 let dir = tempfile::TempDir::new().unwrap();
4787 let (engine, _op, task_id, _worker_token) = seeded_task("planner").await;
4788 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4789
4790 let result = engine
4791 .stage_worker_artifact_trusted(
4792 &task_id,
4793 1,
4794 "../evil.md".to_string(),
4795 serde_json::json!("pwned"),
4796 )
4797 .await;
4798 assert!(
4799 result.is_ok(),
4800 "default (Warn) policy is fail-open even on a rejected part name"
4801 );
4802 // The escaped target (ctx dir's parent) must not have been written.
4803 let escaped = dir
4804 .path()
4805 .join("workspace/tasks")
4806 .join(task_id.as_str())
4807 .join("evil.md");
4808 assert!(
4809 !escaped.exists(),
4810 "a traversal name must never write outside the ctx dir: {escaped:?}"
4811 );
4812 }
4813}
4814
4815/// GH #36 ST1: named multi-part worker output. Covers (a) the pure
4816/// `fold_final_and_parts` assembly `dispatch_attempt_with`'s Final-pull
4817/// delegates to, (b) `stage_worker_artifact_trusted`'s per-attempt
4818/// isolation on `EngineState.output_store` / `.worker_artifact_names` (the
4819/// same `HashMap<(StepId, u32), _>` key shape `submit_worker_result_trusted`
4820/// uses — a fresh attempt is a fresh key, so nothing to explicitly "clean
4821/// up"), and (c) the allowlist behavior that keeps a non-opt-in `Artifact`
4822/// producer (e.g. `AfterRunAuditMiddleware`) from being folded in.
4823#[cfg(test)]
4824mod named_multi_part_worker_output_tests {
4825 use super::*;
4826 use crate::worker::output::{ContentRef, OutputEvent};
4827
4828 fn artifact(name: &str, value: Value) -> OutputEvent {
4829 OutputEvent::Artifact {
4830 name: name.to_string(),
4831 content: ContentRef::Inline { value },
4832 }
4833 }
4834
4835 fn final_ev(value: Value, ok: bool) -> OutputEvent {
4836 OutputEvent::Final {
4837 content: ContentRef::Inline { value },
4838 ok,
4839 }
4840 }
4841
4842 fn names(list: &[&str]) -> Vec<String> {
4843 list.iter().map(|s| s.to_string()).collect()
4844 }
4845
4846 /// Two staged parts (both in `staged_names`) + a `Final` fold into
4847 /// `{"out", "parts"}`, each value carried through verbatim.
4848 #[test]
4849 fn fold_final_and_parts_assembles_out_and_parts_shape() {
4850 let tail = vec![
4851 artifact("summary", serde_json::json!("the summary")),
4852 artifact("diff", serde_json::json!({"lines": 3})),
4853 final_ev(serde_json::json!("final text"), true),
4854 ];
4855 let staged = names(&["summary", "diff"]);
4856 let (value, ok) = fold_final_and_parts(&tail, &staged).expect("Final present");
4857 assert!(ok);
4858 assert_eq!(
4859 value,
4860 serde_json::json!({
4861 "out": "final text",
4862 "parts": {
4863 "summary": "the summary",
4864 "diff": {"lines": 3},
4865 }
4866 })
4867 );
4868 }
4869
4870 /// Zero staged parts: the value is exactly the plain `Final` value — no
4871 /// `{"out", "parts"}` wrapping. This is the back-compat guarantee (GH
4872 /// #36 must not change the shape for a worker that never POSTs to
4873 /// `/v1/worker/artifact`).
4874 #[test]
4875 fn fold_final_and_parts_with_no_parts_returns_plain_final_value() {
4876 let tail = vec![final_ev(serde_json::json!("plain value"), true)];
4877 let (value, ok) = fold_final_and_parts(&tail, &[]).expect("Final present");
4878 assert!(ok);
4879 assert_eq!(value, serde_json::json!("plain value"));
4880 }
4881
4882 /// The same staged part `name` appearing twice in one attempt: the
4883 /// LATER (tail-order) value wins — `parts` is a `Map`, not an
4884 /// accumulating list.
4885 #[test]
4886 fn fold_final_and_parts_same_name_twice_last_write_wins() {
4887 let tail = vec![
4888 artifact("a", serde_json::json!("first")),
4889 artifact("a", serde_json::json!("second")),
4890 final_ev(serde_json::json!("f"), true),
4891 ];
4892 let staged = names(&["a"]);
4893 let (value, _ok) = fold_final_and_parts(&tail, &staged).expect("Final present");
4894 assert_eq!(
4895 value,
4896 serde_json::json!({"out": "f", "parts": {"a": "second"}})
4897 );
4898 }
4899
4900 /// No `Final` anywhere in the tail (only staged parts, e.g. the worker
4901 /// crashed before submitting) — `None`, the caller's pre-existing "no
4902 /// Final in output_tail" error path.
4903 #[test]
4904 fn fold_final_and_parts_returns_none_when_no_final_present() {
4905 let tail = vec![artifact("a", serde_json::json!("v"))];
4906 let staged = names(&["a"]);
4907 assert!(fold_final_and_parts(&tail, &staged).is_none());
4908 }
4909
4910 /// An `Artifact` on the tail whose name is NOT in `staged_names` (e.g.
4911 /// `AfterRunAuditMiddleware`'s `"audit:<step_ref>"` sidecar finding on
4912 /// an audited step's own tail) must NOT be folded into `"parts"` — the
4913 /// value stays the plain `Final` value, exactly the pre-GH-#36
4914 /// behavior for every producer that isn't the worker's own
4915 /// `/v1/worker/artifact` staging. This is the regression this fold was
4916 /// almost shipped without (see `dispatch_attempt_with`'s doc).
4917 #[test]
4918 fn fold_final_and_parts_ignores_artifacts_outside_the_staged_allowlist() {
4919 let tail = vec![
4920 final_ev(serde_json::json!({"echoed": "hi"}), true),
4921 artifact("audit:echo", serde_json::json!({"finding": "clean"})),
4922 ];
4923 // `staged_names` empty: the worker itself never staged anything —
4924 // the audit sidecar Artifact must be ignored.
4925 let (value, ok) = fold_final_and_parts(&tail, &[]).expect("Final present");
4926 assert!(ok);
4927 assert_eq!(value, serde_json::json!({"echoed": "hi"}));
4928 }
4929
4930 /// Mixed tail: one staged (allowlisted) part and one non-staged
4931 /// (audit-style) `Artifact` — only the staged one is folded in.
4932 #[test]
4933 fn fold_final_and_parts_folds_only_the_staged_subset_of_a_mixed_tail() {
4934 let tail = vec![
4935 artifact("summary", serde_json::json!("s")),
4936 artifact("audit:echo", serde_json::json!({"finding": "clean"})),
4937 final_ev(serde_json::json!("f"), true),
4938 ];
4939 let staged = names(&["summary"]);
4940 let (value, _ok) = fold_final_and_parts(&tail, &staged).expect("Final present");
4941 assert_eq!(
4942 value,
4943 serde_json::json!({"out": "f", "parts": {"summary": "s"}})
4944 );
4945 }
4946
4947 /// `stage_worker_artifact_trusted` writes onto the `(task_id, attempt)`
4948 /// key exactly like `submit_worker_result_trusted` does — a part staged
4949 /// under attempt N is invisible to an `output_tail` / allowlist read of
4950 /// attempt N+1 (a fresh attempt starts empty; nothing carries over).
4951 #[tokio::test]
4952 async fn stage_worker_artifact_trusted_is_isolated_per_attempt() {
4953 let engine = Engine::new(EngineCfg::default());
4954 let task_id = StepId::new();
4955
4956 engine
4957 .stage_worker_artifact_trusted(&task_id, 1, "a".to_string(), serde_json::json!("v1"))
4958 .await
4959 .expect("stage attempt 1");
4960
4961 let attempt_1_tail = engine.output_tail(&task_id, 1).await;
4962 assert_eq!(attempt_1_tail.len(), 1);
4963 assert!(matches!(
4964 &attempt_1_tail[0],
4965 OutputEvent::Artifact { name, .. } if name == "a"
4966 ));
4967 assert_eq!(
4968 engine.worker_artifact_names_for(&task_id, 1).await,
4969 vec!["a".to_string()]
4970 );
4971
4972 let attempt_2_tail = engine.output_tail(&task_id, 2).await;
4973 assert!(
4974 attempt_2_tail.is_empty(),
4975 "attempt 2 must not see attempt 1's staged part"
4976 );
4977 assert!(
4978 engine
4979 .worker_artifact_names_for(&task_id, 2)
4980 .await
4981 .is_empty(),
4982 "attempt 2's allowlist must not see attempt 1's staged name"
4983 );
4984 }
4985}
4986
4987// ─── GH #50 (Subtask 2): `Engine::register_verdict_contracts` /
4988// `Engine::verdict_contract_for_task` ────────────────────────────────────
4989#[cfg(test)]
4990mod verdict_contract_registry_tests {
4991 use super::*;
4992
4993 async fn seeded_engine(agent: &str) -> (Engine, StepId) {
4994 let engine = Engine::new(EngineCfg::default());
4995 let op_token = engine
4996 .attach("ut-op", Role::Operator, Duration::from_secs(30))
4997 .await
4998 .expect("attach");
4999 let task_id = engine
5000 .start_task(
5001 &op_token,
5002 TaskSpec {
5003 agent: agent.to_string(),
5004 initial_directive: serde_json::json!("x"),
5005 step_ctx: None,
5006 check_policy: None,
5007 },
5008 )
5009 .await
5010 .expect("start_task");
5011 (engine, task_id)
5012 }
5013
5014 /// An agent with no registered contract at all → `None` (the opt-in
5015 /// default; every pre-GH-#50 `Engine`).
5016 #[tokio::test]
5017 async fn returns_none_when_no_contract_registered_for_the_agent() {
5018 let (engine, task_id) = seeded_engine("gate").await;
5019 assert_eq!(engine.verdict_contract_for_task(&task_id).await, None);
5020 }
5021
5022 /// A registered contract for the running task's agent is returned
5023 /// verbatim.
5024 #[tokio::test]
5025 async fn returns_the_registered_contract_for_the_running_agent() {
5026 let (engine, task_id) = seeded_engine("gate").await;
5027 let contract = mlua_swarm_schema::VerdictContract {
5028 channel: mlua_swarm_schema::VerdictChannel::Body,
5029 values: vec!["PASS".to_string(), "BLOCKED".to_string()],
5030 };
5031 engine.register_verdict_contracts(HashMap::from([("gate".to_string(), contract.clone())]));
5032 assert_eq!(
5033 engine.verdict_contract_for_task(&task_id).await,
5034 Some(contract)
5035 );
5036 }
5037
5038 /// A registered contract for a DIFFERENT agent name never leaks onto
5039 /// an unrelated task.
5040 #[tokio::test]
5041 async fn does_not_leak_a_contract_registered_for_a_different_agent() {
5042 let (engine, task_id) = seeded_engine("gate").await;
5043 engine.register_verdict_contracts(HashMap::from([(
5044 "other-agent".to_string(),
5045 mlua_swarm_schema::VerdictContract {
5046 channel: mlua_swarm_schema::VerdictChannel::Body,
5047 values: vec!["PASS".to_string()],
5048 },
5049 )]));
5050 assert_eq!(engine.verdict_contract_for_task(&task_id).await, None);
5051 }
5052
5053 /// An unknown `task_id` → `None`, not a panic / error.
5054 #[tokio::test]
5055 async fn returns_none_for_an_unknown_task_id() {
5056 let engine = Engine::new(EngineCfg::default());
5057 let unknown = StepId::new();
5058 assert_eq!(engine.verdict_contract_for_task(&unknown).await, None);
5059 }
5060
5061 /// `register_verdict_contracts` is additive (`HashMap::extend`): a
5062 /// second call registering a DIFFERENT agent does not clobber the
5063 /// first call's entry.
5064 #[tokio::test]
5065 async fn register_verdict_contracts_is_additive_across_calls() {
5066 let (engine, task_id) = seeded_engine("gate").await;
5067 let contract = mlua_swarm_schema::VerdictContract {
5068 channel: mlua_swarm_schema::VerdictChannel::Part,
5069 values: vec!["ALLOW".to_string()],
5070 };
5071 engine.register_verdict_contracts(HashMap::from([("gate".to_string(), contract.clone())]));
5072 engine.register_verdict_contracts(HashMap::from([(
5073 "unrelated-agent".to_string(),
5074 mlua_swarm_schema::VerdictContract {
5075 channel: mlua_swarm_schema::VerdictChannel::Body,
5076 values: vec!["X".to_string()],
5077 },
5078 )]));
5079 assert_eq!(
5080 engine.verdict_contract_for_task(&task_id).await,
5081 Some(contract)
5082 );
5083 }
5084}
5085
5086// ─── GH #51: completion-time verdict-contract enforcement — the shared
5087// `Engine::verdict_contract_completion_check` choke point embedded inside
5088// `submit_worker_result_trusted` / `submit_output`, exercised here at the
5089// `submit_output` level (the WS Operator fallback route's own unit-test
5090// coverage — see `crates/mlua-swarm-server/tests/verdict_contract.rs` for
5091// the HTTP-round-trip coverage of the other 2 routes) ───────────────────
5092#[cfg(test)]
5093mod verdict_contract_completion_tests {
5094 use super::*;
5095
5096 /// Seeds a `Pending` task bound to `agent` and mints a bound
5097 /// `Role::Worker` token for it — the same mint-and-register pattern
5098 /// `initial_directive_value_passthrough_tests::mint_worker_token`
5099 /// uses (duplicated here: that helper is private to its own sibling
5100 /// `#[cfg(test)]` module, not reachable via `super::*` from this one).
5101 async fn seeded_task_with_worker_token(agent: &str) -> (Engine, CapToken, StepId) {
5102 let engine = Engine::new(EngineCfg::default());
5103 let op_token = engine
5104 .attach("ut-op", Role::Operator, Duration::from_secs(30))
5105 .await
5106 .expect("attach");
5107 let task_id = engine
5108 .start_task(
5109 &op_token,
5110 TaskSpec {
5111 agent: agent.to_string(),
5112 initial_directive: serde_json::json!("x"),
5113 step_ctx: None,
5114 check_policy: None,
5115 },
5116 )
5117 .await
5118 .expect("start_task");
5119 let worker_token = engine.signer().session(
5120 format!("worker-of-{task_id}"),
5121 Role::Worker,
5122 vec!["*".into()],
5123 Duration::from_secs(600),
5124 );
5125 let fp = worker_token.fingerprint();
5126 let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
5127 engine
5128 .with_state("test.mint_worker", move |s| {
5129 s.tokens.insert(fp, record);
5130 })
5131 .await
5132 .expect("mint worker token");
5133 (engine, worker_token, task_id)
5134 }
5135
5136 fn body_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
5137 mlua_swarm_schema::VerdictContract {
5138 channel: mlua_swarm_schema::VerdictChannel::Body,
5139 values: values.iter().map(|v| v.to_string()).collect(),
5140 }
5141 }
5142
5143 fn part_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
5144 mlua_swarm_schema::VerdictContract {
5145 channel: mlua_swarm_schema::VerdictChannel::Part,
5146 values: values.iter().map(|v| v.to_string()).collect(),
5147 }
5148 }
5149
5150 fn final_event(value: Value, ok: bool) -> crate::worker::output::OutputEvent {
5151 crate::worker::output::OutputEvent::Final {
5152 content: crate::worker::output::ContentRef::Inline { value },
5153 ok,
5154 }
5155 }
5156
5157 /// Route 3 (WS Operator fallback, `submit_output` level) — a
5158 /// `channel: "part"` contract's attempt completes via a plain
5159 /// `Final` without ever staging a `"verdict"` artifact: rejected
5160 /// with `EngineError::VerdictPartMissing`, and nothing lands on
5161 /// `output_tail` — the rejected value never reaches the flow ctx.
5162 #[tokio::test]
5163 async fn submit_output_rejects_missing_verdict_part() {
5164 let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
5165 engine.register_verdict_contracts(HashMap::from([(
5166 "gate".to_string(),
5167 part_contract(&["PASS", "BLOCKED"]),
5168 )]));
5169
5170 let err = engine
5171 .submit_output(
5172 &token,
5173 &task_id,
5174 1,
5175 final_event(serde_json::json!("anything"), true),
5176 )
5177 .await
5178 .expect_err("missing staged verdict part must be rejected");
5179 assert!(
5180 matches!(err, EngineError::VerdictPartMissing { .. }),
5181 "unexpected error variant: {err:?}"
5182 );
5183
5184 let tail = engine.output_tail(&task_id, 1).await;
5185 assert!(
5186 !tail
5187 .iter()
5188 .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
5189 "a rejected completion must not write a Final onto output_tail"
5190 );
5191 }
5192
5193 /// Route 3 — a `channel: "part"` contract completes normally when the
5194 /// worker DID stage a matching `"verdict"` artifact first (defense in
5195 /// depth: presence AND membership both hold).
5196 #[tokio::test]
5197 async fn submit_output_accepts_when_verdict_part_is_staged_and_a_member() {
5198 let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
5199 engine.register_verdict_contracts(HashMap::from([(
5200 "gate".to_string(),
5201 part_contract(&["PASS", "BLOCKED"]),
5202 )]));
5203 engine
5204 .stage_worker_artifact_trusted(
5205 &task_id,
5206 1,
5207 "verdict".to_string(),
5208 serde_json::json!("PASS"),
5209 )
5210 .await
5211 .expect("stage verdict part");
5212
5213 engine
5214 .submit_output(
5215 &token,
5216 &task_id,
5217 1,
5218 final_event(serde_json::json!("full report"), true),
5219 )
5220 .await
5221 .expect("staged + member verdict part must be accepted");
5222
5223 let tail = engine.output_tail(&task_id, 1).await;
5224 assert!(
5225 tail.iter()
5226 .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
5227 "an accepted completion must write its Final onto output_tail"
5228 );
5229 }
5230
5231 /// Route 3 — a `channel: "body"` contract's completing value is NOT a
5232 /// member of `values`: rejected with
5233 /// `EngineError::VerdictValueRejected`, no `Final` written.
5234 #[tokio::test]
5235 async fn submit_output_rejects_body_value_outside_contract() {
5236 let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
5237 engine.register_verdict_contracts(HashMap::from([(
5238 "gate".to_string(),
5239 body_contract(&["PASS", "BLOCKED"]),
5240 )]));
5241
5242 let err = engine
5243 .submit_output(
5244 &token,
5245 &task_id,
5246 1,
5247 final_event(serde_json::json!("UNKNOWN"), true),
5248 )
5249 .await
5250 .expect_err("out-of-contract body value must be rejected");
5251 match err {
5252 EngineError::VerdictValueRejected { value, allowed } => {
5253 assert_eq!(value, "UNKNOWN");
5254 assert_eq!(allowed, vec!["PASS".to_string(), "BLOCKED".to_string()]);
5255 }
5256 other => panic!("unexpected error variant: {other:?}"),
5257 }
5258
5259 let tail = engine.output_tail(&task_id, 1).await;
5260 assert!(
5261 !tail
5262 .iter()
5263 .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
5264 "a rejected completion must not write a Final onto output_tail"
5265 );
5266 }
5267
5268 /// `ok=false` bypasses the completion-time check entirely, regardless
5269 /// of channel or membership — the exemption acceptance criterion,
5270 /// exercised at the `submit_output` choke point.
5271 #[tokio::test]
5272 async fn submit_output_ok_false_bypasses_the_check() {
5273 let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
5274 engine.register_verdict_contracts(HashMap::from([(
5275 "gate".to_string(),
5276 body_contract(&["PASS", "BLOCKED"]),
5277 )]));
5278
5279 engine
5280 .submit_output(
5281 &token,
5282 &task_id,
5283 1,
5284 final_event(serde_json::json!("UNKNOWN"), false),
5285 )
5286 .await
5287 .expect("ok=false must bypass the verdict contract check entirely");
5288
5289 let tail = engine.output_tail(&task_id, 1).await;
5290 assert!(
5291 tail.iter()
5292 .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
5293 "an ok=false completion is exempt, not rejected — its Final must still land"
5294 );
5295 }
5296
5297 /// `staged_verdict_value_for` mirrors `fold_final_and_parts`'s
5298 /// last-write-wins semantics: staging `"verdict"` twice within the
5299 /// same attempt returns the LAST value, not the first.
5300 #[tokio::test]
5301 async fn staged_verdict_value_for_is_last_write_wins() {
5302 let (engine, _token, task_id) = seeded_task_with_worker_token("gate").await;
5303 engine
5304 .stage_worker_artifact_trusted(
5305 &task_id,
5306 1,
5307 "verdict".to_string(),
5308 serde_json::json!("PASS"),
5309 )
5310 .await
5311 .expect("stage first verdict part");
5312 engine
5313 .stage_worker_artifact_trusted(
5314 &task_id,
5315 1,
5316 "verdict".to_string(),
5317 serde_json::json!("BLOCKED"),
5318 )
5319 .await
5320 .expect("stage second verdict part");
5321
5322 assert_eq!(
5323 engine.staged_verdict_value_for(&task_id, 1).await,
5324 Some("BLOCKED".to_string())
5325 );
5326 }
5327
5328 /// `staged_verdict_value_for` ignores artifacts staged under any name
5329 /// OTHER than the literal `"verdict"` — mirrors `channel: "part"`
5330 /// contracts only ever addressing that one part.
5331 #[tokio::test]
5332 async fn staged_verdict_value_for_ignores_other_artifact_names() {
5333 let (engine, _token, task_id) = seeded_task_with_worker_token("gate").await;
5334 engine
5335 .stage_worker_artifact_trusted(
5336 &task_id,
5337 1,
5338 "notes".to_string(),
5339 serde_json::json!("irrelevant"),
5340 )
5341 .await
5342 .expect("stage unrelated part");
5343
5344 assert_eq!(engine.staged_verdict_value_for(&task_id, 1).await, None);
5345 }
5346
5347 /// `staged_verdict_value_for` → `None` when nothing was ever staged —
5348 /// the normal case the completion check turns into
5349 /// `EngineError::VerdictPartMissing`.
5350 #[tokio::test]
5351 async fn staged_verdict_value_for_returns_none_when_nothing_staged() {
5352 let (engine, _token, task_id) = seeded_task_with_worker_token("gate").await;
5353 assert_eq!(engine.staged_verdict_value_for(&task_id, 1).await, None);
5354 }
5355}
5356
5357// ─── GH #76 Skip tier: DispatchOutcome::Skip tier + SubmitOutcome API ────────────
5358#[cfg(test)]
5359mod skip_tier_tests {
5360 use super::*;
5361 use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerFactory};
5362 use crate::blueprint::EngineDispatcher;
5363 use crate::core::state::{
5364 is_skip_marker, unwrap_skip_marker, wrap_skip_marker, SubmitOutcome, SKIP_MARKER_KEY,
5365 };
5366 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
5367 use crate::types::{RunId, TaskId};
5368 use crate::worker::adapter::WorkerResult;
5369 use mlua_flow_ir::AsyncDispatcher;
5370 use mlua_swarm_schema::{AgentDef, AgentKind};
5371 use serde_json::json;
5372
5373 /// `DispatchOutcome::Skip(v)` roundtrips through serde JSON without
5374 /// loss — the enum is serialized with the default externally-tagged
5375 /// form (same as `Pass`/`Blocked`), so no `#[serde(...)]` tuning is
5376 /// needed for the new variant.
5377 #[test]
5378 fn dispatch_outcome_skip_variant_serializes_roundtrip() {
5379 let outcome = DispatchOutcome::Skip(json!({ "verdict": "SKIP", "reason": "n/a" }));
5380 let serialized = serde_json::to_string(&outcome).expect("serialize");
5381 let round: DispatchOutcome = serde_json::from_str(&serialized).expect("deserialize");
5382 match round {
5383 DispatchOutcome::Skip(v) => {
5384 assert_eq!(v, json!({ "verdict": "SKIP", "reason": "n/a" }));
5385 }
5386 other => panic!("expected Skip after roundtrip, got {other:?}"),
5387 }
5388 }
5389
5390 /// The `is_skip_marker` / `unwrap_skip_marker` / `wrap_skip_marker`
5391 /// helper triangle round-trips consistently and rejects plain
5392 /// payloads. Pinning the reserved-key contract in a unit test guards
5393 /// against a future edit accidentally renaming the sentinel key
5394 /// (which would silently break every downstream reader).
5395 #[test]
5396 fn skip_marker_helpers_wrap_detect_and_unwrap() {
5397 assert!(!is_skip_marker(&json!("plain string")));
5398 assert!(!is_skip_marker(&json!({ "verdict": "PASS" })));
5399 assert!(!is_skip_marker(&json!(null)));
5400
5401 let inner = json!({ "reason": "not applicable" });
5402 let wrapped = wrap_skip_marker(inner.clone());
5403 assert!(is_skip_marker(&wrapped));
5404 assert_eq!(wrapped[SKIP_MARKER_KEY], json!(true));
5405 assert_eq!(unwrap_skip_marker(&wrapped), Some(inner));
5406
5407 // A malformed sentinel (marker key present but `value` absent) is
5408 // still a Skip signal, defaulting the carried payload to Null so
5409 // downstream match arms never observe `None` on a marker match.
5410 let malformed = json!({ SKIP_MARKER_KEY: true });
5411 assert!(is_skip_marker(&malformed));
5412 assert_eq!(unwrap_skip_marker(&malformed), Some(Value::Null));
5413
5414 // Plain payloads → `unwrap_skip_marker` returns `None` (the
5415 // caller falls back to the ordinary Pass/Blocked path).
5416 assert_eq!(unwrap_skip_marker(&json!("plain")), None);
5417 }
5418
5419 /// The `SubmitOutcome::Skip` mapping wraps the payload in the
5420 /// skip-marker sentinel AND records `Final.ok = true` — matching the
5421 /// invariant in the outcome mapping table in
5422 /// `submit_worker_result_trusted`'s doc. This is the wire shape
5423 /// `dispatch_attempt_with*` reads back to route into
5424 /// `DispatchOutcome::Skip`.
5425 #[tokio::test]
5426 async fn submit_worker_result_trusted_skip_outcome_records_final_ok_true_with_sentinel() {
5427 use crate::worker::output::OutputEvent;
5428 let engine = Engine::new(EngineCfg::default());
5429 let op_token = engine
5430 .attach("ut-op", Role::Operator, Duration::from_secs(30))
5431 .await
5432 .expect("attach");
5433 let task_id = engine
5434 .start_task(
5435 &op_token,
5436 TaskSpec {
5437 agent: "analyst".into(),
5438 initial_directive: json!("go"),
5439 step_ctx: None,
5440 check_policy: None,
5441 },
5442 )
5443 .await
5444 .expect("start_task");
5445
5446 let inner_verdict = json!({ "verdict": "SKIP", "reason": "migration=no" });
5447 engine
5448 .submit_worker_result_trusted(&task_id, 1, inner_verdict.clone(), SubmitOutcome::Skip)
5449 .await
5450 .expect("submit with Skip outcome");
5451
5452 let tail = engine.output_tail(&task_id, 1).await;
5453 let final_ev = tail
5454 .iter()
5455 .rev()
5456 .find_map(|ev| match ev {
5457 OutputEvent::Final { content, ok } => Some((content.clone(), *ok)),
5458 _ => None,
5459 })
5460 .expect("Final present after Skip submit");
5461 assert!(
5462 final_ev.1,
5463 "Skip records Final.ok = true (flow-continuation)"
5464 );
5465 let stored_value = super::content_ref_to_value(final_ev.0);
5466 assert!(
5467 is_skip_marker(&stored_value),
5468 "Skip wraps the payload in the sentinel: got {stored_value}"
5469 );
5470 assert_eq!(unwrap_skip_marker(&stored_value), Some(inner_verdict));
5471 }
5472
5473 /// The new `SubmitOutcome::Pass` / `SubmitOutcome::Blocked` arms
5474 /// preserve byte-for-byte the pre-#76 wire shape (Final.ok mirrors
5475 /// the tier; the value is not wrapped). Regression against a future
5476 /// edit that accidentally routes Pass/Blocked through the Skip
5477 /// wrapper.
5478 #[tokio::test]
5479 async fn submit_worker_result_trusted_pass_and_blocked_wire_unchanged() {
5480 use crate::worker::output::OutputEvent;
5481 let engine = Engine::new(EngineCfg::default());
5482 let op_token = engine
5483 .attach("ut-op", Role::Operator, Duration::from_secs(30))
5484 .await
5485 .expect("attach");
5486
5487 // Pass path.
5488 let pass_task = engine
5489 .start_task(
5490 &op_token,
5491 TaskSpec {
5492 agent: "worker".into(),
5493 initial_directive: json!("go"),
5494 step_ctx: None,
5495 check_policy: None,
5496 },
5497 )
5498 .await
5499 .expect("start_task pass");
5500 engine
5501 .submit_worker_result_trusted(&pass_task, 1, json!("pass-value"), SubmitOutcome::Pass)
5502 .await
5503 .expect("submit Pass");
5504 let pass_tail = engine.output_tail(&pass_task, 1).await;
5505 let (pass_content, pass_ok) = pass_tail
5506 .iter()
5507 .rev()
5508 .find_map(|ev| match ev {
5509 OutputEvent::Final { content, ok } => Some((content.clone(), *ok)),
5510 _ => None,
5511 })
5512 .expect("Final present");
5513 assert!(pass_ok);
5514 assert_eq!(
5515 super::content_ref_to_value(pass_content),
5516 json!("pass-value"),
5517 "Pass value must not be wrapped"
5518 );
5519
5520 // Blocked path.
5521 let blocked_task = engine
5522 .start_task(
5523 &op_token,
5524 TaskSpec {
5525 agent: "worker".into(),
5526 initial_directive: json!("go"),
5527 step_ctx: None,
5528 check_policy: None,
5529 },
5530 )
5531 .await
5532 .expect("start_task blocked");
5533 engine
5534 .submit_worker_result_trusted(
5535 &blocked_task,
5536 1,
5537 json!("blocked-value"),
5538 SubmitOutcome::Blocked,
5539 )
5540 .await
5541 .expect("submit Blocked");
5542 let blocked_tail = engine.output_tail(&blocked_task, 1).await;
5543 let (blocked_content, blocked_ok) = blocked_tail
5544 .iter()
5545 .rev()
5546 .find_map(|ev| match ev {
5547 OutputEvent::Final { content, ok } => Some((content.clone(), *ok)),
5548 _ => None,
5549 })
5550 .expect("Final present");
5551 assert!(!blocked_ok);
5552 assert_eq!(
5553 super::content_ref_to_value(blocked_content),
5554 json!("blocked-value"),
5555 "Blocked value must not be wrapped"
5556 );
5557 }
5558
5559 /// End-to-end (engine layer): a worker that returns a skip-marker
5560 /// sentinel value via `WorkerResult { value: wrap_skip_marker(inner),
5561 /// ok: true }` — which is what a Skip-aware caller of
5562 /// `submit_worker_result_trusted(..., SubmitOutcome::Skip)` places on
5563 /// the wire — is folded by `dispatch_attempt_with_run_ctx` into
5564 /// `DispatchOutcome::Skip(inner)`. Proves the sentinel → outcome
5565 /// routing that the flow-ir binding boundary depends on.
5566 #[tokio::test]
5567 async fn dispatcher_folds_skip_sentinel_into_skip_outcome() {
5568 let inner_verdict = json!({ "verdict": "SKIP", "reason": "not applicable" });
5569 let inner_for_worker = inner_verdict.clone();
5570 let factory = RustFnInProcessSpawnerFactory::new().register_fn("analyst", move |_inv| {
5571 let value = wrap_skip_marker(inner_for_worker.clone());
5572 async move { Ok(WorkerResult { value, ok: true }) }
5573 });
5574 let def = AgentDef {
5575 name: "analyst".into(),
5576 kind: AgentKind::RustFn,
5577 spec: json!({ "fn_id": "analyst" }),
5578 profile: None,
5579 meta: None,
5580 runner: None,
5581 runner_ref: None,
5582 verdict: None,
5583 };
5584 let spawner = factory.build(&def, None).expect("build");
5585
5586 let engine = Engine::new(EngineCfg::default());
5587 let op_token = engine
5588 .attach("ut-op", Role::Operator, Duration::from_secs(30))
5589 .await
5590 .expect("attach");
5591 let task_id = engine
5592 .start_task(
5593 &op_token,
5594 TaskSpec {
5595 agent: "analyst".into(),
5596 initial_directive: json!("go"),
5597 step_ctx: None,
5598 check_policy: None,
5599 },
5600 )
5601 .await
5602 .expect("start_task");
5603
5604 let outcome = engine
5605 .dispatch_attempt_with_run_ctx(&op_token, &task_id, &spawner, None)
5606 .await
5607 .expect("dispatch ok");
5608
5609 match outcome {
5610 DispatchOutcome::Skip(v) => {
5611 assert_eq!(v, inner_verdict, "Skip carries the unwrapped inner verdict");
5612 }
5613 other => panic!("expected DispatchOutcome::Skip, got {other:?}"),
5614 }
5615 }
5616
5617 /// `EngineDispatcher::dispatch` (the `AsyncDispatcher` impl flow-ir
5618 /// invokes) maps `DispatchOutcome::Skip(v)` to `Ok(wrap_skip_marker(v))`
5619 /// — a successful return whose Value carries the sentinel across the
5620 /// flow-ir boundary. Pinning this mapping in a test guards the arm
5621 /// order (a wildcard `Ok(other) =>` arm accidentally placed BEFORE the
5622 /// Skip arm would route Skip to `EvalError::DispatcherError` and
5623 /// abort the flow — the exact failure mode this tier prevents).
5624 #[tokio::test]
5625 async fn engine_dispatcher_maps_skip_outcome_to_ok_sentinel_value() {
5626 let inner_verdict = json!({ "verdict": "SKIP", "reason": "not applicable" });
5627 let inner_for_worker = inner_verdict.clone();
5628 let factory = RustFnInProcessSpawnerFactory::new().register_fn("analyst", move |_inv| {
5629 let value = wrap_skip_marker(inner_for_worker.clone());
5630 async move { Ok(WorkerResult { value, ok: true }) }
5631 });
5632 let def = AgentDef {
5633 name: "analyst".into(),
5634 kind: AgentKind::RustFn,
5635 spec: json!({ "fn_id": "analyst" }),
5636 profile: None,
5637 meta: None,
5638 runner: None,
5639 runner_ref: None,
5640 verdict: None,
5641 };
5642 let spawner = factory.build(&def, None).expect("build");
5643
5644 let engine = Engine::new(EngineCfg::default());
5645 let op_token = engine
5646 .attach("ut-op", Role::Operator, Duration::from_secs(30))
5647 .await
5648 .expect("attach");
5649 let dispatcher = EngineDispatcher::with_spawner(engine.clone(), op_token, spawner);
5650
5651 let out = dispatcher
5652 .dispatch("analyst", json!("go"))
5653 .await
5654 .expect("dispatch returns Ok for Skip tier (not EvalError::DispatcherError)");
5655
5656 assert!(
5657 is_skip_marker(&out),
5658 "returned value must carry the skip-marker sentinel across the flow-ir boundary: got {out}"
5659 );
5660 assert_eq!(unwrap_skip_marker(&out), Some(inner_verdict));
5661 }
5662
5663 /// `EngineDispatcher::dispatch`'s `RunContext` step-entry log records
5664 /// `status = "skipped"` for a Skip completion (distinct from
5665 /// `"passed"` / `"blocked"`), so post-run inspection of
5666 /// `RunRecord.step_entries` can distinguish flow-continuation-with-
5667 /// binding-write from flow-continuation-without-binding-write.
5668 #[tokio::test]
5669 async fn engine_dispatcher_step_entry_status_is_skipped_for_skip_outcome() {
5670 let inner_verdict = json!({ "verdict": "SKIP" });
5671 let inner_for_worker = inner_verdict.clone();
5672 let factory = RustFnInProcessSpawnerFactory::new().register_fn("analyst", move |_inv| {
5673 let value = wrap_skip_marker(inner_for_worker.clone());
5674 async move { Ok(WorkerResult { value, ok: true }) }
5675 });
5676 let def = AgentDef {
5677 name: "analyst".into(),
5678 kind: AgentKind::RustFn,
5679 spec: json!({ "fn_id": "analyst" }),
5680 profile: None,
5681 meta: None,
5682 runner: None,
5683 runner_ref: None,
5684 verdict: None,
5685 };
5686 let spawner = factory.build(&def, None).expect("build");
5687
5688 let engine = Engine::new(EngineCfg::default());
5689 let op_token = engine
5690 .attach("ut-op", Role::Operator, Duration::from_secs(30))
5691 .await
5692 .expect("attach");
5693
5694 // Seed a RunContext with an InMemoryRunStore so the dispatcher
5695 // appends a step_entry we can then read back.
5696 let run_id = RunId::new();
5697 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
5698 run_store
5699 .create(RunRecord {
5700 id: run_id.clone(),
5701 task_id: TaskId::new(),
5702 status: RunStatus::Running,
5703 step_entries: Vec::new(),
5704 degradations: Vec::new(),
5705 operator_sid: None,
5706 result_ref: None,
5707 input_json: None,
5708 created_at: 0,
5709 updated_at: 0,
5710 })
5711 .await
5712 .expect("create run record");
5713 let run_ctx = RunContext::new(run_id.clone(), run_store.clone());
5714
5715 let dispatcher =
5716 EngineDispatcher::with_spawner(engine.clone(), op_token, spawner).with_run(run_ctx);
5717
5718 let out = dispatcher
5719 .dispatch("analyst", json!("go"))
5720 .await
5721 .expect("dispatch ok");
5722 assert!(is_skip_marker(&out));
5723
5724 let record = run_store.get(&run_id).await.expect("run record present");
5725 let step = record
5726 .step_entries
5727 .first()
5728 .expect("at least one step_entry appended for the dispatched step");
5729 assert_eq!(
5730 step.status.as_deref(),
5731 Some("skipped"),
5732 "Skip outcome must record StepEntry.status = \"skipped\""
5733 );
5734 assert_eq!(step.step_ref.as_deref(), Some("analyst"));
5735 }
5736}