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