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 ) {
2121 let task_id_for_lookup = task_id.clone();
2122 let lookup = self
2123 .with_state("materialize_final_submission.lookup", move |s| {
2124 let producer_agent = s
2125 .tasks
2126 .get(&task_id_for_lookup)
2127 .map(|t| t.spec.agent.clone());
2128 let view = s
2129 .agent_ctx
2130 .get(&(task_id_for_lookup.clone(), attempt))
2131 .map(|e| e.view.clone());
2132 (producer_agent, view)
2133 })
2134 .await;
2135 let (producer_agent, view) = match lookup {
2136 Ok(pair) => pair,
2137 Err(err) => {
2138 tracing::warn!(
2139 %task_id,
2140 error = %err,
2141 "submit-time projection sink: state lookup failed; skipping (fail-open)"
2142 );
2143 return;
2144 }
2145 };
2146 let Some(producer_agent) = producer_agent else {
2147 // Defensive only: `task_id` is always a just-looked-up task at
2148 // every real call site. No task, no addressable producer name
2149 // — nothing to project.
2150 return;
2151 };
2152 let placement = self
2153 .projection_placement_for(task_id)
2154 .await
2155 .unwrap_or_default();
2156 let root = view.and_then(|v| placement.resolve_root(&v));
2157
2158 // GH #23 subtask-2: resolve `producer_agent` to its canonical
2159 // projection name via the Blueprint-wide `StepNaming` table
2160 // snapshotted at dispatch time (`Engine::step_naming_for`). Both
2161 // write paths below ((a) data-plane, (b) file stem) use the
2162 // *canonical* name — `StepNaming::canonical_of_producer` returns
2163 // `producer_agent` unchanged for undeclared steps (byte-identical
2164 // to pre-GH-#23 behavior), and `None` (no table for this
2165 // `task_id`, e.g. a spawn that never went through
2166 // `EngineDispatcher::with_step_naming`) is a defensive fail-open
2167 // to the raw `producer_agent`, same discipline as the rest of this
2168 // sink.
2169 let canonical_agent = self
2170 .step_naming_for(task_id)
2171 .await
2172 .and_then(|naming| {
2173 naming
2174 .canonical_of_producer(&producer_agent)
2175 .map(str::to_string)
2176 })
2177 .unwrap_or_else(|| producer_agent.clone());
2178
2179 // (a) Data-plane dual-write, when an OutputStore backend is wired.
2180 if let Some(store) = self.output_store_backend() {
2181 if let Err(err) = store
2182 .append(
2183 task_id.as_str(),
2184 attempt,
2185 &canonical_agent,
2186 crate::worker::output::OutputEvent::Final {
2187 content: content.clone(),
2188 ok,
2189 },
2190 Vec::new(),
2191 )
2192 .await
2193 {
2194 tracing::warn!(
2195 %task_id,
2196 agent = %producer_agent,
2197 canonical = %canonical_agent,
2198 error = %err,
2199 "submit-time projection sink: OutputStore dual-write failed (fail-open)"
2200 );
2201 }
2202 }
2203
2204 // (b) File materialize, when a root resolved.
2205 let Some(root) = root else {
2206 tracing::warn!(
2207 %task_id,
2208 agent = %producer_agent,
2209 canonical = %canonical_agent,
2210 "submit-time projection sink: no work_dir/project_root resolved; skipping file materialize (fail-open)"
2211 );
2212 return;
2213 };
2214 let value = match content {
2215 crate::worker::output::ContentRef::Inline { value } => value.clone(),
2216 crate::worker::output::ContentRef::FileRef {
2217 path,
2218 mime,
2219 size_hint,
2220 } => serde_json::json!({
2221 "file_ref": path.to_string_lossy(),
2222 "mime": mime,
2223 "size_hint": size_hint,
2224 }),
2225 };
2226 let key = crate::core::projection::ProjectionKey {
2227 task_id: task_id.to_string(),
2228 run_id: None,
2229 step: Some(canonical_agent.clone()),
2230 path: None,
2231 };
2232 let adapter = crate::core::projection::FileProjectionAdapter::with_placement(
2233 root,
2234 (*placement).clone(),
2235 );
2236 if let Err(err) = adapter.materialize_submission(&key, &value, attempt, ok) {
2237 tracing::warn!(
2238 %task_id,
2239 agent = %producer_agent,
2240 canonical = %canonical_agent,
2241 error = %err,
2242 "submit-time projection sink: file materialize failed (fail-open)"
2243 );
2244 }
2245 }
2246
2247 /// Submit-time projection sink for `OutputEvent::Artifact` (GH #34
2248 /// subtask-3 gap fix). Data-plane-only sibling of
2249 /// [`Self::materialize_final_submission`]: when [`Self::set_output_store`]
2250 /// has wired a Data-plane [`crate::store::output::OutputStore`], the
2251 /// artifact dual-writes there under its own `name`, verbatim — general
2252 /// form, every `Artifact` submitted via [`Self::submit_output`]
2253 /// materializes this way, no name-prefix gate (see `submit_output`'s
2254 /// doc, "`Artifact` dual-write" section, for the full rationale).
2255 ///
2256 /// Deliberately does NOT resolve `producer_agent` / `StepNaming` /
2257 /// `AgentContextView` / a `root` the way `materialize_final_submission`
2258 /// does — an artifact's `name` already IS the Data-plane key (no
2259 /// canonicalization needed), and this sink does not drive the
2260 /// file-materialize half, so none of that lookup is needed here. Same
2261 /// fail-open discipline: an unconfigured `OutputStore`, or the write
2262 /// erroring, only logs a `tracing::warn!` and never surfaces to the
2263 /// caller (`submit_output` already committed the domain-plane append
2264 /// before calling this sink).
2265 async fn materialize_artifact_submission(
2266 &self,
2267 task_id: &StepId,
2268 attempt: u32,
2269 name: &str,
2270 content: &crate::worker::output::ContentRef,
2271 ) {
2272 let Some(store) = self.output_store_backend() else {
2273 return;
2274 };
2275 if let Err(err) = store
2276 .append(
2277 task_id.as_str(),
2278 attempt,
2279 name,
2280 crate::worker::output::OutputEvent::Artifact {
2281 name: name.to_string(),
2282 content: content.clone(),
2283 },
2284 Vec::new(),
2285 )
2286 .await
2287 {
2288 tracing::warn!(
2289 %task_id,
2290 artifact = %name,
2291 error = %err,
2292 "submit-time projection sink: OutputStore dual-write failed for Artifact (fail-open)"
2293 );
2294 }
2295 }
2296
2297 /// Snapshot the entire output tail for a given `(task_id, attempt)`.
2298 /// Used by the dispatch path when pulling `Final`, and by observers
2299 /// reading the trace.
2300 pub async fn output_tail(
2301 &self,
2302 task_id: &StepId,
2303 attempt: u32,
2304 ) -> Vec<crate::worker::output::OutputEvent> {
2305 let key = (task_id.clone(), attempt);
2306 self.with_state("output_tail", move |s| {
2307 s.output_store.get(&key).cloned().unwrap_or_default()
2308 })
2309 .await
2310 .unwrap_or_default()
2311 }
2312
2313 /// Record an interim `last_result` for `task_id` without changing its
2314 /// `status`. Distinct from the terminal `Final` output event handled
2315 /// through `submit_output` / `dispatch_attempt_with`.
2316 pub async fn post_result(
2317 &self,
2318 token: &CapToken,
2319 task_id: &StepId,
2320 result: Value,
2321 ) -> Result<(), EngineError> {
2322 self.verify_token_for_task(token, Verb::PostResult, task_id)
2323 .await?;
2324 let task_id = task_id.clone();
2325 let result_clone = result.clone();
2326 self.with_state("post_result", move |s| {
2327 let task = s
2328 .tasks
2329 .get_mut(&task_id)
2330 .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))?;
2331 task.last_result = Some(result_clone);
2332 task.updated_at = now_unix();
2333 Ok::<(), EngineError>(())
2334 })
2335 .await??;
2336 Ok(())
2337 }
2338
2339 /// Store a named resource value, retrievable later via `fetch_data`.
2340 /// No token is required — this is a server-side/admin-style setter
2341 /// (mirrors `bake_worker_system_prompt`).
2342 pub async fn set_resource(
2343 &self,
2344 key: impl Into<String>,
2345 value: Value,
2346 ) -> Result<(), EngineError> {
2347 let key = key.into();
2348 self.with_state("set_resource", move |s| {
2349 s.resources.insert(key, value);
2350 })
2351 .await?;
2352 Ok(())
2353 }
2354
2355 // ═══════════════════════════════════════════════════════════════════════
2356 // Senior suspend / resume
2357 // ═══════════════════════════════════════════════════════════════════════
2358
2359 /// Ask a question of the Senior, mark the task `Suspended`, and
2360 /// return a `ResumeKey`. The suspended state persists until another
2361 /// task calls `resume(key, answer)`.
2362 ///
2363 /// Resume-side waiting is `Notify`-based, so a caller (typically
2364 /// MainAI) can detach, reattach from a different process, and still
2365 /// pull the answer out via `await_resume(key, timeout)` — the answer
2366 /// is stored inside `EngineState`.
2367 pub async fn query_senior(
2368 &self,
2369 token: &CapToken,
2370 task_id: &StepId,
2371 question: Value,
2372 ) -> Result<ResumeKey, EngineError> {
2373 self.verify_token(token, Verb::QuerySenior).await?;
2374 let task_id = task_id.clone();
2375 let key = ResumeKey::for_senior(&task_id);
2376 let task_notify = self
2377 .with_state("query_senior.notify_ensure", |s| {
2378 s.ensure_task_notify(&task_id)
2379 })
2380 .await?;
2381
2382 let key_clone = key.clone();
2383 let task_id_inner = task_id.clone();
2384 let question_clone = question.clone();
2385 self.with_state("query_senior.suspend", move |s| {
2386 let task = s
2387 .tasks
2388 .get_mut(&task_id_inner)
2389 .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))?;
2390 task.status = TaskStatus::Suspended;
2391 task.suspended_on = Some(key_clone.clone());
2392 task.updated_at = now_unix();
2393 s.pending_resumes
2394 .insert(key_clone.clone(), ResumePending::new());
2395 s.push_event(Event::SeniorQueried {
2396 task_id: task_id_inner.clone(),
2397 question: question_clone.clone(),
2398 });
2399 s.push_event(Event::TaskSuspended {
2400 task_id: task_id_inner.clone(),
2401 key: key_clone.clone(),
2402 });
2403 Ok::<(), EngineError>(())
2404 })
2405 .await??;
2406
2407 // Notify callers waiting for a task status change (Running → Suspended).
2408 task_notify.notify_waiters();
2409
2410 let _ = self
2411 .inner
2412 .event_tx
2413 .send(Event::SeniorQueried { task_id, question });
2414 Ok(key)
2415 }
2416
2417 /// Store the answer for a `ResumeKey` in `EngineState` and wake the
2418 /// waiting caller via `Notify`. Also flips the suspended task's
2419 /// status back to `Running` and fires the per-task notifier.
2420 pub async fn resume(&self, key: ResumeKey, answer: Value) -> Result<(), EngineError> {
2421 let answer_for_state = answer.clone();
2422 let answer_for_event = answer.clone();
2423 let key_clone = key.clone();
2424 let (notify, task_notify, task_id_opt) = self
2425 .with_state("resume.set", move |s| {
2426 let pending = s
2427 .pending_resumes
2428 .get_mut(&key_clone)
2429 .ok_or(EngineError::ResumeKeyNotFound)?;
2430 pending.answer = Some(answer_for_state);
2431 let notify = pending.notify.clone();
2432
2433 let task_id = s
2434 .tasks
2435 .iter()
2436 .find(|(_, t)| t.suspended_on.as_ref() == Some(&key_clone))
2437 .map(|(id, _)| id.clone());
2438
2439 let task_notify = task_id.as_ref().map(|tid| s.ensure_task_notify(tid));
2440
2441 if let Some(tid) = &task_id {
2442 if let Some(task) = s.tasks.get_mut(tid) {
2443 task.suspended_on = None;
2444 task.status = TaskStatus::Running;
2445 task.updated_at = now_unix();
2446 }
2447 s.push_event(Event::TaskResumed {
2448 task_id: tid.clone(),
2449 key: key_clone.clone(),
2450 });
2451 s.push_event(Event::SeniorAnswered {
2452 task_id: tid.clone(),
2453 answer: answer_for_event.clone(),
2454 });
2455 }
2456 Ok::<_, EngineError>((notify, task_notify, task_id))
2457 })
2458 .await??;
2459
2460 // Outside the lock: notify_waiters for both the ResumePending and task-status waits.
2461 notify.notify_waiters();
2462 if let Some(n) = task_notify {
2463 n.notify_waiters();
2464 }
2465
2466 if let Some(tid) = task_id_opt {
2467 let _ = self
2468 .inner
2469 .event_tx
2470 .send(Event::TaskResumed { task_id: tid, key });
2471 }
2472 Ok(())
2473 }
2474
2475 /// Wait for the resume answer. Even if the caller (an Operator)
2476 /// detached and reattached, the answer is available immediately here
2477 /// — if it was already stored, this returns without waiting on the
2478 /// notifier.
2479 ///
2480 /// `timeout = Duration::ZERO` performs an instant check without
2481 /// waiting.
2482 pub async fn await_resume(
2483 &self,
2484 key: ResumeKey,
2485 timeout: Duration,
2486 ) -> Result<Value, EngineError> {
2487 // (1) Under the lock: clone the notify handle and check for an existing answer.
2488 let key_clone = key.clone();
2489 let (notify, existing) = self
2490 .with_state("await_resume.snapshot", move |s| {
2491 let pending = s
2492 .pending_resumes
2493 .get(&key_clone)
2494 .ok_or(EngineError::ResumeKeyNotFound)?;
2495 Ok::<_, EngineError>((pending.notify.clone(), pending.answer.clone()))
2496 })
2497 .await??;
2498
2499 // (2) If an answer has already been stored, return immediately (detach / reattach pattern).
2500 if let Some(v) = existing {
2501 return Ok(v);
2502 }
2503
2504 // (3) Outside the lock: wait on the notify with a timeout.
2505 if timeout.is_zero() {
2506 return Err(EngineError::PollTimeout);
2507 }
2508 let waited = tokio::time::timeout(timeout, notify.notified()).await;
2509 if waited.is_err() {
2510 return Err(EngineError::PollTimeout);
2511 }
2512
2513 // (4) Under the lock: re-read the answer (should be present now that we were notified).
2514 let key_clone = key.clone();
2515 self.with_state("await_resume.read", move |s| {
2516 let pending = s
2517 .pending_resumes
2518 .get(&key_clone)
2519 .ok_or(EngineError::ResumeKeyNotFound)?;
2520 pending
2521 .answer
2522 .clone()
2523 .ok_or_else(|| EngineError::Internal("notified but answer missing".into()))
2524 })
2525 .await?
2526 }
2527
2528 // ═══════════════════════════════════════════════════════════════════════
2529 // poll_task — the "wait" path that waits for task-status changes (works for long-poll and regular wait).
2530 // ═══════════════════════════════════════════════════════════════════════
2531
2532 /// Wait until the task's status **transitions to terminal or
2533 /// `Suspended`**, then return the latest `TaskState`. Returns
2534 /// immediately if the task is already in a terminal state.
2535 /// Exceeding the timeout returns `EngineError::PollTimeout`.
2536 ///
2537 /// A `hold` of `Duration::from_secs(0)` returns a snapshot immediately
2538 /// (no wait). Larger holds — tens of minutes up to days — are fine;
2539 /// the wait state is kept in memory inside the engine and does not
2540 /// degrade.
2541 pub async fn poll_task(
2542 &self,
2543 token: &CapToken,
2544 task_id: &StepId,
2545 hold: Duration,
2546 ) -> Result<TaskState, EngineError> {
2547 self.verify_token_for_task(token, Verb::PollTask, task_id)
2548 .await?;
2549 let task_id_inner = task_id.clone();
2550
2551 // (1) Under the lock: take a snapshot and clone task_notify.
2552 let (state, notify) = self
2553 .with_state("poll_task.snapshot", move |s| {
2554 let task = s
2555 .tasks
2556 .get(&task_id_inner)
2557 .cloned()
2558 .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))?;
2559 let notify = s.ensure_task_notify(&task_id_inner);
2560 Ok::<_, EngineError>((task, notify))
2561 })
2562 .await??;
2563
2564 // (2) Immediate-return condition: already terminal / Suspended (nothing left to wait on).
2565 if matches!(
2566 state.status,
2567 TaskStatus::Pass | TaskStatus::Blocked | TaskStatus::Cancelled | TaskStatus::Suspended
2568 ) {
2569 return Ok(state);
2570 }
2571 if hold.is_zero() {
2572 return Ok(state);
2573 }
2574
2575 // (3) Outside the lock: wait on Notify with a timeout.
2576 let waited = tokio::time::timeout(hold, notify.notified()).await;
2577 if waited.is_err() {
2578 return Err(EngineError::PollTimeout);
2579 }
2580
2581 // (4) Under the lock: take a fresh snapshot.
2582 let task_id_inner = task_id.clone();
2583 self.with_state("poll_task.reread", move |s| {
2584 s.tasks
2585 .get(&task_id_inner)
2586 .cloned()
2587 .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))
2588 })
2589 .await?
2590 }
2591
2592 // ═══════════════════════════════════════════════════════════════════════
2593 // Background: heartbeat miss → detach loop
2594 // ═══════════════════════════════════════════════════════════════════════
2595
2596 /// Background loop that scans sessions every `heartbeat_interval` and
2597 /// flips `attached = false` on any session whose `last_seen` exceeds
2598 /// `heartbeat_miss_threshold * interval`.
2599 ///
2600 /// The tasks themselves are kept (assuming
2601 /// `keepalive_on_idle = true`), so another client can reattach with
2602 /// the same token and resume immediately. Dropping the returned
2603 /// `JoinHandle` does not stop the loop — the handle exists so callers
2604 /// who want to abort can hold onto it.
2605 pub fn start_detach_loop(&self) -> tokio::task::JoinHandle<()> {
2606 let engine = self.clone();
2607 let cfg = self.inner.cfg.long_hold.clone();
2608 let interval = cfg.heartbeat_interval;
2609 let miss_secs = cfg.heartbeat_interval.as_secs() * cfg.heartbeat_miss_threshold as u64;
2610
2611 tokio::spawn(async move {
2612 let mut ticker = tokio::time::interval(interval);
2613 ticker.tick().await; // first tick is immediate
2614 loop {
2615 ticker.tick().await;
2616 let now = now_unix();
2617 let detached = engine
2618 .with_state("detach_loop.scan", |s| {
2619 let mut detached = Vec::new();
2620 for (sid, sess) in s.sessions.iter_mut() {
2621 if !sess.attached {
2622 continue;
2623 }
2624 if now.saturating_sub(sess.last_seen) >= miss_secs {
2625 sess.attached = false;
2626 detached.push(sid.clone());
2627 }
2628 }
2629 for sid in &detached {
2630 s.push_event(Event::SessionDetached {
2631 session_id: sid.clone(),
2632 });
2633 }
2634 detached
2635 })
2636 .await
2637 .unwrap_or_default();
2638 for sid in detached {
2639 let _ = engine
2640 .inner
2641 .event_tx
2642 .send(Event::SessionDetached { session_id: sid });
2643 }
2644 }
2645 })
2646 }
2647
2648 /// Helper: wake a task whose status has changed. Called from the
2649 /// method body outside the lock.
2650 async fn wake_task(&self, task_id: &StepId) -> Result<(), EngineError> {
2651 let task_id = task_id.clone();
2652 let notify_opt = self
2653 .with_state("wake_task.get_notify", move |s| {
2654 s.task_notifies.get(&task_id).cloned()
2655 })
2656 .await?;
2657 if let Some(n) = notify_opt {
2658 n.notify_waiters();
2659 }
2660 Ok(())
2661 }
2662}
2663
2664// ─── UT: issue #14 — token store keyed by fingerprint, not nonce ────────────
2665#[cfg(test)]
2666mod token_fingerprint_store_tests {
2667 use super::*;
2668
2669 /// A token that was never attached fails verify with a `TokenNotFound`
2670 /// that carries the fingerprint — never the nonce. The error string can
2671 /// surface in HTTP error bodies, so this is the secret-hygiene contract.
2672 #[tokio::test]
2673 async fn verify_unknown_token_reports_fingerprint_not_nonce() {
2674 let engine = Engine::new(EngineCfg::default());
2675 // Signed by the engine's own signer (sig passes) but never inserted
2676 // into the store — verify must fail at step (4), the store lookup.
2677 let token = engine.signer().session(
2678 "ghost",
2679 Role::Operator,
2680 vec!["*".into()],
2681 Duration::from_secs(60),
2682 );
2683 let err = engine
2684 .verify_token(&token, Verb::ReadTaskState)
2685 .await
2686 .expect_err("token is not in the store");
2687 let msg = err.to_string();
2688 assert!(
2689 msg.contains(&token.fingerprint()),
2690 "error must carry the fingerprint: {msg}"
2691 );
2692 assert!(
2693 !msg.contains(&token.nonce),
2694 "error must not leak the nonce: {msg}"
2695 );
2696 }
2697
2698 /// attach → verify → heartbeat → detach all resolve the session /
2699 /// token record through fingerprint keys (mint/verify lifecycle
2700 /// regression guard for the issue #14 key migration).
2701 #[tokio::test]
2702 async fn attach_verify_heartbeat_detach_cycle_with_fp_keying() {
2703 let engine = Engine::new(EngineCfg::default());
2704 let token = engine
2705 .attach("op-1", Role::Operator, Duration::from_secs(60))
2706 .await
2707 .expect("attach");
2708 engine
2709 .verify_token(&token, Verb::ReadTaskState)
2710 .await
2711 .expect("verify consumes via fp key");
2712 engine
2713 .heartbeat(&token)
2714 .await
2715 .expect("heartbeat finds the session by fp");
2716 engine
2717 .detach(&token)
2718 .await
2719 .expect("detach finds the session by fp");
2720 }
2721}
2722
2723// ─── UT: `OperatorKind` "Runtime Global" tier — `Option` semantics ─────────
2724//
2725// Regression coverage for the "explicit Automate is indistinguishable from
2726// unspecified" defect: `OperatorSession.operator_kind` (and the
2727// `attach_with_ids` `kind` parameter it stores) is `Option<OperatorKind>`,
2728// so `Some(Automate)` is an explicit Runtime Global request that must
2729// outrank `bp_global`, while `None` must let `bp_global` decide. Exercises
2730// the real `resolve_operator_info` cascade path (not just
2731// `collapse_operator_kind` in isolation), attaching via `attach_with_ids`
2732// exactly as `TaskLaunchService::launch` does.
2733#[cfg(test)]
2734mod resolve_operator_info_runtime_global_tests {
2735 use super::*;
2736
2737 async fn attach_and_resolve(
2738 runtime_global: Option<OperatorKind>,
2739 bp_global: Option<OperatorKind>,
2740 ) -> OperatorInfo {
2741 let engine = Engine::new(EngineCfg::default());
2742 let token = engine
2743 .attach_with_ids(
2744 "ut-op",
2745 Role::Operator,
2746 Duration::from_secs(30),
2747 runtime_global,
2748 None,
2749 None,
2750 None,
2751 HashMap::new(),
2752 HashMap::new(),
2753 bp_global,
2754 )
2755 .await
2756 .expect("attach_with_ids ok");
2757 let session = engine
2758 .with_state("test.find_session", |s| {
2759 s.sessions
2760 .values()
2761 .find(|sess| sess.token_fp == token.fingerprint())
2762 .cloned()
2763 })
2764 .await
2765 .expect("with_state ok")
2766 .expect("session present after attach_with_ids");
2767 engine.resolve_operator_info(&session, "agent-x").await
2768 }
2769
2770 #[tokio::test]
2771 async fn explicit_some_automate_outranks_bp_global_main_ai() {
2772 // Runtime Global explicitly requests Automate; bp_global is MainAi.
2773 // The explicit `Some(Automate)` must win — this is exactly the case
2774 // the old `== OperatorKind::default()` convention got wrong (it
2775 // could not tell "explicitly Automate" from "unspecified" and would
2776 // have let `bp_global` (MainAi) take over instead).
2777 let info =
2778 attach_and_resolve(Some(OperatorKind::Automate), Some(OperatorKind::MainAi)).await;
2779 assert_eq!(
2780 info.kind,
2781 OperatorKind::Automate,
2782 "explicit Some(Automate) runtime_global must outrank bp_global MainAi"
2783 );
2784 }
2785
2786 #[tokio::test]
2787 async fn none_lets_bp_global_main_ai_win() {
2788 // Runtime Global left unspecified (`None`); bp_global is MainAi.
2789 // With nothing more specific set, `bp_global` must decide.
2790 let info = attach_and_resolve(None, Some(OperatorKind::MainAi)).await;
2791 assert_eq!(
2792 info.kind,
2793 OperatorKind::MainAi,
2794 "None runtime_global must let bp_global MainAi win"
2795 );
2796 }
2797}
2798
2799/// issue #13 run_id propagation: `dispatch_attempt_with`'s `run_id` param
2800/// must land in `Ctx.meta.runtime["run_id"]` (the same slot pattern as the
2801/// pre-existing `worker_handle`), or be omitted entirely when `None`. Same
2802/// `CtxProbe` shape as `middleware::worker_binding`'s test module — an
2803/// inner `SpawnerAdapter` that snapshots the `Ctx` it was called with and
2804/// fails the spawn (only the ctx snapshot matters here).
2805#[cfg(test)]
2806mod dispatch_attempt_with_run_id_tests {
2807 use super::*;
2808 use crate::worker::adapter::{SpawnError, SpawnerAdapter};
2809 use crate::worker::Worker;
2810 use std::sync::Mutex as StdMutex;
2811
2812 struct CtxProbe {
2813 seen: Arc<StdMutex<Option<Ctx>>>,
2814 }
2815
2816 #[async_trait::async_trait]
2817 impl SpawnerAdapter for CtxProbe {
2818 async fn spawn(
2819 &self,
2820 _engine: &Engine,
2821 ctx: &Ctx,
2822 _task_id: StepId,
2823 _attempt: u32,
2824 _token: CapToken,
2825 ) -> Result<Box<dyn Worker>, SpawnError> {
2826 *self.seen.lock().unwrap() = Some(ctx.clone());
2827 Err(SpawnError::Internal("probe stop".into()))
2828 }
2829 }
2830
2831 async fn dispatch_with_probe(run_id: Option<&RunId>) -> Ctx {
2832 let engine = Engine::new(EngineCfg::default());
2833 let token = engine
2834 .attach("ut-op", Role::Operator, Duration::from_secs(30))
2835 .await
2836 .expect("attach");
2837 let tid = engine
2838 .start_task(
2839 &token,
2840 TaskSpec {
2841 agent: "probe".into(),
2842 initial_directive: "hi".into(),
2843 step_ctx: None,
2844 },
2845 )
2846 .await
2847 .expect("start_task");
2848 let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
2849 let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
2850 // The probe always errors the spawn (`SpawnError::Internal`); we
2851 // only care about the `Ctx` snapshot it captured, so the dispatch
2852 // outcome itself (`Err`) is discarded.
2853 let _ = engine
2854 .dispatch_attempt_with(&token, &tid, &spawner, run_id)
2855 .await;
2856 let captured = seen.lock().unwrap().clone();
2857 captured.expect("inner ctx captured")
2858 }
2859
2860 #[tokio::test]
2861 async fn run_id_lands_in_ctx_meta_runtime_when_some() {
2862 let run_id = RunId::new();
2863 let observed = dispatch_with_probe(Some(&run_id)).await;
2864 assert_eq!(
2865 observed.meta.runtime.get("run_id").and_then(|v| v.as_str()),
2866 Some(run_id.as_str()),
2867 "ctx.meta.runtime[\"run_id\"] must carry the run_id passed to dispatch_attempt_with"
2868 );
2869 }
2870
2871 #[tokio::test]
2872 async fn run_id_key_absent_when_none() {
2873 let observed = dispatch_with_probe(None).await;
2874 assert!(
2875 !observed.meta.runtime.contains_key("run_id"),
2876 "no run_id key must be injected when dispatch_attempt_with is called with None"
2877 );
2878 }
2879}
2880
2881/// GH #21 Phase 2: `TaskSpec.step_ctx` must land in
2882/// `Ctx.meta.runtime[STEP_CTX_KEY]` — re-read from the spec on EVERY
2883/// attempt (the prep closure re-reads `task.spec.step_ctx` every call, not
2884/// caching it once at `start_task`), so a retry (attempt 2) carries it
2885/// too. Same `CtxProbe` shape as `dispatch_attempt_with_run_id_tests`.
2886#[cfg(test)]
2887mod dispatch_attempt_with_step_ctx_tests {
2888 use super::*;
2889 use crate::worker::adapter::{SpawnError, SpawnerAdapter};
2890 use crate::worker::Worker;
2891 use std::sync::Mutex as StdMutex;
2892
2893 struct CtxProbe {
2894 seen: Arc<StdMutex<Option<Ctx>>>,
2895 }
2896
2897 #[async_trait::async_trait]
2898 impl SpawnerAdapter for CtxProbe {
2899 async fn spawn(
2900 &self,
2901 _engine: &Engine,
2902 ctx: &Ctx,
2903 _task_id: StepId,
2904 _attempt: u32,
2905 _token: CapToken,
2906 ) -> Result<Box<dyn Worker>, SpawnError> {
2907 *self.seen.lock().unwrap() = Some(ctx.clone());
2908 Err(SpawnError::Internal("probe stop".into()))
2909 }
2910 }
2911
2912 #[tokio::test]
2913 async fn step_ctx_lands_in_ctx_meta_runtime_on_attempt_1_and_2() {
2914 let engine = Engine::new(EngineCfg::default());
2915 let token = engine
2916 .attach("ut-op", Role::Operator, Duration::from_secs(30))
2917 .await
2918 .expect("attach");
2919 let tid = engine
2920 .start_task(
2921 &token,
2922 TaskSpec {
2923 agent: "probe".into(),
2924 initial_directive: "hi".into(),
2925 step_ctx: Some(serde_json::json!({ "work_dir": "/step" })),
2926 },
2927 )
2928 .await
2929 .expect("start_task");
2930 let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
2931 let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
2932
2933 // The probe always errors the spawn; only the ctx snapshot matters.
2934 let _ = engine
2935 .dispatch_attempt_with(&token, &tid, &spawner, None)
2936 .await;
2937 let first = seen
2938 .lock()
2939 .unwrap()
2940 .clone()
2941 .expect("attempt 1 ctx captured");
2942 assert_eq!(
2943 first.meta.runtime.get(STEP_CTX_KEY),
2944 Some(&serde_json::json!({ "work_dir": "/step" })),
2945 "attempt 1 must carry TaskSpec.step_ctx in ctx.meta.runtime[STEP_CTX_KEY]"
2946 );
2947
2948 let _ = engine
2949 .dispatch_attempt_with(&token, &tid, &spawner, None)
2950 .await;
2951 let second = seen
2952 .lock()
2953 .unwrap()
2954 .clone()
2955 .expect("attempt 2 ctx captured");
2956 assert_eq!(
2957 second.meta.runtime.get(STEP_CTX_KEY),
2958 Some(&serde_json::json!({ "work_dir": "/step" })),
2959 "attempt 2 (retry) must ALSO carry TaskSpec.step_ctx — prep re-reads the spec every attempt"
2960 );
2961 }
2962
2963 #[tokio::test]
2964 async fn step_ctx_key_absent_when_none() {
2965 let engine = Engine::new(EngineCfg::default());
2966 let token = engine
2967 .attach("ut-op", Role::Operator, Duration::from_secs(30))
2968 .await
2969 .expect("attach");
2970 let tid = engine
2971 .start_task(
2972 &token,
2973 TaskSpec {
2974 agent: "probe".into(),
2975 initial_directive: "hi".into(),
2976 step_ctx: None,
2977 },
2978 )
2979 .await
2980 .expect("start_task");
2981 let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
2982 let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
2983 let _ = engine
2984 .dispatch_attempt_with(&token, &tid, &spawner, None)
2985 .await;
2986 let observed = seen.lock().unwrap().clone().expect("ctx captured");
2987 assert!(
2988 !observed.meta.runtime.contains_key(STEP_CTX_KEY),
2989 "no step_ctx key must be injected when TaskSpec.step_ctx is None"
2990 );
2991 }
2992}
2993
2994// ─── issue #18: `TaskSpec.initial_directive` `Value` pass-through ──────────
2995#[cfg(test)]
2996mod initial_directive_value_passthrough_tests {
2997 use super::*;
2998
2999 async fn seeded_engine(initial_directive: Value) -> (Engine, CapToken, StepId) {
3000 let engine = Engine::new(EngineCfg::default());
3001 let op_token = engine
3002 .attach("ut-op", Role::Operator, Duration::from_secs(30))
3003 .await
3004 .expect("attach");
3005 let task_id = engine
3006 .start_task(
3007 &op_token,
3008 TaskSpec {
3009 agent: "planner".to_string(),
3010 initial_directive,
3011 step_ctx: None,
3012 },
3013 )
3014 .await
3015 .expect("start_task");
3016 (engine, op_token, task_id)
3017 }
3018
3019 /// Mint + register a `Role::Worker` token the same way
3020 /// `dispatch_attempt_with` does — `fetch_prompt` is worker-verb-gated.
3021 async fn mint_worker_token(engine: &Engine, task_id: &StepId) -> CapToken {
3022 let worker_token = engine.signer().session(
3023 format!("worker-of-{task_id}"),
3024 Role::Worker,
3025 vec!["*".into()],
3026 Duration::from_secs(600),
3027 );
3028 let fp = worker_token.fingerprint();
3029 let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
3030 engine
3031 .with_state("test.mint_worker", move |s| {
3032 s.tokens.insert(fp, record);
3033 })
3034 .await
3035 .expect("mint worker token");
3036 worker_token
3037 }
3038
3039 /// `EngineDispatcher::dispatch` no longer stringifies the evaluated
3040 /// `Step.in` value before seeding `TaskSpec.initial_directive` — an
3041 /// Object seed must round-trip through `start_task` /
3042 /// `read_task_state` byte-for-byte as the same `Value::Object`, not a
3043 /// JSON-stringified `Value::String`.
3044 #[tokio::test]
3045 async fn object_seed_passes_through_task_spec_unchanged() {
3046 let seed = serde_json::json!({"key": "value"});
3047 let (engine, token, task_id) = seeded_engine(seed.clone()).await;
3048 let state = engine
3049 .read_task_state(&token, &task_id)
3050 .await
3051 .expect("read_task_state");
3052 assert_eq!(
3053 state.spec.initial_directive, seed,
3054 "TaskSpec.initial_directive must equal the raw Object seed, not a stringified copy"
3055 );
3056 }
3057
3058 /// `Engine::fetch_prompt` returns the `Value` end-to-end (issue #18):
3059 /// an Object seed stays a `Value::Object` and is not stringified in
3060 /// the engine layer. The Worker HTTP boundary
3061 /// (`fetch_worker_payload*`) is what performs the render down to a
3062 /// JSON literal `String` for `WorkerPayload.prompt`.
3063 #[tokio::test]
3064 async fn object_seed_passes_through_fetch_prompt_as_value() {
3065 let seed = serde_json::json!({"key": "value"});
3066 let (engine, _token, task_id) = seeded_engine(seed.clone()).await;
3067 let worker_token = mint_worker_token(&engine, &task_id).await;
3068 let prompt = engine
3069 .fetch_prompt(&worker_token, &task_id)
3070 .await
3071 .expect("fetch_prompt");
3072 assert_eq!(
3073 prompt, seed,
3074 "fetch_prompt must return the raw Object Value, not a stringified copy"
3075 );
3076 }
3077
3078 /// The Worker HTTP boundary is the render point: `fetch_worker_payload*`
3079 /// coerces the stored `Value` down to `WorkerPayload.prompt: String`
3080 /// (JSON-literal shape for non-strings). Verifies the boundary render
3081 /// stays intact for an Object seed.
3082 #[tokio::test]
3083 async fn object_seed_renders_as_json_literal_at_worker_payload_boundary() {
3084 let seed = serde_json::json!({"key": "value"});
3085 let (engine, _token, task_id) = seeded_engine(seed).await;
3086 let worker_token = mint_worker_token(&engine, &task_id).await;
3087 let payload = engine
3088 .fetch_worker_payload(&worker_token, &task_id)
3089 .await
3090 .expect("fetch_worker_payload");
3091 assert_eq!(
3092 payload.prompt, r#"{"key":"value"}"#,
3093 "WorkerPayload.prompt must be the JSON literal String render of the Value seed"
3094 );
3095 }
3096
3097 /// A `String` seed is unaffected — still passes through verbatim, both
3098 /// as the `TaskSpec.initial_directive` `Value` and as the Worker
3099 /// `fetch_prompt` return (issue #18 Invariant 2).
3100 #[tokio::test]
3101 async fn string_seed_passes_through_unchanged() {
3102 let (engine, token, task_id) = seeded_engine(serde_json::json!("do the thing")).await;
3103 let state = engine
3104 .read_task_state(&token, &task_id)
3105 .await
3106 .expect("read_task_state");
3107 assert_eq!(
3108 state.spec.initial_directive,
3109 serde_json::json!("do the thing")
3110 );
3111 let worker_token = mint_worker_token(&engine, &task_id).await;
3112 let prompt = engine
3113 .fetch_prompt(&worker_token, &task_id)
3114 .await
3115 .expect("fetch_prompt");
3116 assert_eq!(prompt, serde_json::json!("do the thing"));
3117 }
3118}
3119
3120/// GH #31: `fetch_worker_payload{,_trusted}`'s size-threshold branch
3121/// between inline (`WorkerPayload.system`) and by-reference
3122/// (`WorkerPayload.system_ref`) delivery, plus the `bake_worker_system_prompt`
3123/// `agent_render_sizes` bookkeeping that feeds `agent_last_rendered_size`.
3124#[cfg(test)]
3125mod system_ref_threshold_tests {
3126 use super::*;
3127
3128 async fn seeded_engine_with_cfg(cfg: EngineCfg) -> (Engine, CapToken, StepId) {
3129 let engine = Engine::new(cfg);
3130 let op_token = engine
3131 .attach("ut-op", Role::Operator, Duration::from_secs(30))
3132 .await
3133 .expect("attach");
3134 let task_id = engine
3135 .start_task(
3136 &op_token,
3137 TaskSpec {
3138 agent: "planner".to_string(),
3139 initial_directive: serde_json::json!("do the thing"),
3140 step_ctx: None,
3141 },
3142 )
3143 .await
3144 .expect("start_task");
3145 (engine, op_token, task_id)
3146 }
3147
3148 /// Same worker-token-minting fixture as
3149 /// `initial_directive_value_passthrough_tests::mint_worker_token`
3150 /// (kept local to this module — the two `mod`s do not share private
3151 /// helpers across `cfg(test)` boundaries).
3152 async fn mint_worker_token(engine: &Engine, task_id: &StepId) -> CapToken {
3153 let worker_token = engine.signer().session(
3154 format!("worker-of-{task_id}"),
3155 Role::Worker,
3156 vec!["*".into()],
3157 Duration::from_secs(600),
3158 );
3159 let fp = worker_token.fingerprint();
3160 let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
3161 engine
3162 .with_state("test.mint_worker", move |s| {
3163 s.tokens.insert(fp, record);
3164 })
3165 .await
3166 .expect("mint worker token");
3167 worker_token
3168 }
3169
3170 /// Under-threshold: `system` stays inline, `system_ref` stays `None`.
3171 #[tokio::test]
3172 async fn under_threshold_stays_inline() {
3173 let (engine, _op_token, task_id) = seeded_engine_with_cfg(EngineCfg::default()).await;
3174 let worker_token = mint_worker_token(&engine, &task_id).await;
3175 let rendered = "a short system prompt".to_string();
3176 engine
3177 .bake_worker_system_prompt(&task_id, 1, Some(rendered.clone()))
3178 .await
3179 .expect("bake");
3180 let payload = engine
3181 .fetch_worker_payload(&worker_token, &task_id)
3182 .await
3183 .expect("fetch_worker_payload");
3184 assert_eq!(payload.system, Some(rendered));
3185 assert!(payload.system_ref.is_none());
3186 }
3187
3188 /// Over-threshold: `system` is cleared and `system_ref` is populated
3189 /// with a `sha256` matching the known input string. Exercises
3190 /// `fetch_worker_payload_trusted` (the `_trusted` sibling must be
3191 /// behaviorally identical to `fetch_worker_payload`).
3192 #[tokio::test]
3193 async fn over_threshold_switches_to_system_ref_with_matching_sha256() {
3194 let mut cfg = EngineCfg::default();
3195 cfg.system_ref.threshold_bytes = 16;
3196 cfg.system_ref.mode = crate::types::SystemRefMode::File;
3197 cfg.system_ref.store_dir =
3198 std::env::temp_dir().join(format!("mse-system-ref-test-{}", crate::types::now_unix()));
3199 let (engine, _op_token, task_id) = seeded_engine_with_cfg(cfg).await;
3200 let rendered =
3201 "this system prompt is deliberately longer than the 16 byte threshold".to_string();
3202 engine
3203 .bake_worker_system_prompt(&task_id, 1, Some(rendered.clone()))
3204 .await
3205 .expect("bake");
3206 let payload = engine
3207 .fetch_worker_payload_trusted(&task_id)
3208 .await
3209 .expect("fetch_worker_payload_trusted");
3210 assert!(
3211 payload.system.is_none(),
3212 "over-threshold response must not also inline `system`"
3213 );
3214 let system_ref = payload
3215 .system_ref
3216 .expect("over-threshold response must populate system_ref");
3217 assert_eq!(system_ref.size_bytes, rendered.len() as u64);
3218 assert_eq!(system_ref.mode, crate::types::SystemRefMode::File);
3219 use sha2::Digest;
3220 let expected_sha256 = hex::encode(sha2::Sha256::digest(rendered.as_bytes()));
3221 assert_eq!(system_ref.sha256, expected_sha256);
3222 assert!(system_ref.uri.starts_with("file://"));
3223 let written = tokio::fs::read_to_string(system_ref.uri.trim_start_matches("file://"))
3224 .await
3225 .expect("File mode must have written the referenced path");
3226 assert_eq!(written, rendered);
3227 }
3228
3229 /// `Http` mode never writes a file — `system_ref.uri` is the bare path
3230 /// the engine can construct on its own, scheme/host-free.
3231 #[tokio::test]
3232 async fn over_threshold_http_mode_constructs_path_only_uri() {
3233 let mut cfg = EngineCfg::default();
3234 cfg.system_ref.threshold_bytes = 16;
3235 cfg.system_ref.mode = crate::types::SystemRefMode::Http;
3236 let (engine, _op_token, task_id) = seeded_engine_with_cfg(cfg).await;
3237 let worker_token = mint_worker_token(&engine, &task_id).await;
3238 let rendered =
3239 "this system prompt is deliberately longer than the 16 byte threshold".to_string();
3240 engine
3241 .bake_worker_system_prompt(&task_id, 1, Some(rendered))
3242 .await
3243 .expect("bake");
3244 let payload = engine
3245 .fetch_worker_payload(&worker_token, &task_id)
3246 .await
3247 .expect("fetch_worker_payload");
3248 let system_ref = payload.system_ref.expect("system_ref must be populated");
3249 assert_eq!(system_ref.mode, crate::types::SystemRefMode::Http);
3250 assert_eq!(
3251 system_ref.uri,
3252 format!("/v1/worker/prompt/system?task_id={task_id}&attempt=1")
3253 );
3254 }
3255
3256 /// `bake_worker_system_prompt` records the render size keyed by agent
3257 /// name (last-write-wins), readable via `agent_last_rendered_size`.
3258 #[tokio::test]
3259 async fn bake_records_agent_render_size_last_write_wins() {
3260 let (engine, _op_token, task_id) = seeded_engine_with_cfg(EngineCfg::default()).await;
3261 assert_eq!(engine.agent_last_rendered_size("planner").await, None);
3262 engine
3263 .bake_worker_system_prompt(&task_id, 1, Some("a".repeat(10)))
3264 .await
3265 .expect("bake 1");
3266 assert_eq!(engine.agent_last_rendered_size("planner").await, Some(10));
3267 engine
3268 .bake_worker_system_prompt(&task_id, 2, Some("b".repeat(20)))
3269 .await
3270 .expect("bake 2");
3271 assert_eq!(
3272 engine.agent_last_rendered_size("planner").await,
3273 Some(20),
3274 "most-recently-observed size wins, not the largest"
3275 );
3276 }
3277}
3278
3279/// subtask-4 / ST2 rework: `submit_output` / `submit_worker_result_trusted`'s
3280/// submit-time projection sink (`Engine::materialize_final_submission`) —
3281/// the Data-plane `OutputStore` dual-write plus the
3282/// `FileProjectionAdapter`-backed file materialize, both fail-open. See
3283/// the subtask-4 Tests this module covers inline on each test.
3284#[cfg(test)]
3285mod submit_time_projection_sink_tests {
3286 use super::*;
3287 use crate::core::agent_context::AgentContextView;
3288 use crate::store::output::{ContentRef, InMemoryOutputStore, OutputEvent};
3289
3290 /// Starts a task under `agent`, returning `(engine, op_token, task_id,
3291 /// worker_token)` — same helper shape as the sibling test modules
3292 /// above (`initial_directive_value_passthrough_tests::seeded_engine` /
3293 /// `mint_worker_token`), duplicated locally per this file's
3294 /// established per-module convention.
3295 async fn seeded_task(agent: &str) -> (Engine, CapToken, StepId, CapToken) {
3296 let engine = Engine::new(EngineCfg::default());
3297 let op_token = engine
3298 .attach("ut-op", Role::Operator, Duration::from_secs(30))
3299 .await
3300 .expect("attach");
3301 let task_id = engine
3302 .start_task(
3303 &op_token,
3304 TaskSpec {
3305 agent: agent.to_string(),
3306 initial_directive: Value::String("go".into()),
3307 step_ctx: None,
3308 },
3309 )
3310 .await
3311 .expect("start_task");
3312 let worker_token = engine.signer().session(
3313 format!("worker-of-{task_id}"),
3314 Role::Worker,
3315 vec!["*".into()],
3316 Duration::from_secs(600),
3317 );
3318 let fp = worker_token.fingerprint();
3319 let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
3320 engine
3321 .with_state("test.mint_worker", move |s| {
3322 s.tokens.insert(fp, record);
3323 })
3324 .await
3325 .expect("mint worker token");
3326 (engine, op_token, task_id, worker_token)
3327 }
3328
3329 /// Seeds `EngineState.agent_ctx[(task_id, attempt)].view` directly —
3330 /// the same snapshot `AgentContextMiddleware` writes at spawn time
3331 /// (see its module doc), stood up here without the full spawner
3332 /// stack so these tests can exercise `submit_output` in isolation.
3333 async fn seed_agent_context(engine: &Engine, task_id: &StepId, attempt: u32, work_dir: &str) {
3334 let task_id = task_id.clone();
3335 let work_dir = work_dir.to_string();
3336 engine
3337 .with_state("test.seed_agent_context", move |s| {
3338 s.agent_ctx.insert(
3339 (task_id, attempt),
3340 crate::core::state::AgentCtxEntry {
3341 view: AgentContextView {
3342 work_dir: Some(work_dir),
3343 ..Default::default()
3344 },
3345 policy: Default::default(),
3346 },
3347 );
3348 })
3349 .await
3350 .expect("seed agent_ctx");
3351 }
3352
3353 /// GH #27 (follow-up to #23): seeds `EngineState.agent_ctx` with an
3354 /// arbitrary `work_dir` / `project_root` pair (either may be `None`),
3355 /// unlike [`seed_agent_context`] (which only ever sets `work_dir`) —
3356 /// lets these tests exercise `ProjectionPlacement::resolve_root`'s
3357 /// fallback in both directions.
3358 async fn seed_agent_context_roots(
3359 engine: &Engine,
3360 task_id: &StepId,
3361 attempt: u32,
3362 work_dir: Option<&str>,
3363 project_root: Option<&str>,
3364 ) {
3365 let task_id = task_id.clone();
3366 let work_dir = work_dir.map(str::to_string);
3367 let project_root = project_root.map(str::to_string);
3368 engine
3369 .with_state("test.seed_agent_context_roots", move |s| {
3370 s.agent_ctx.insert(
3371 (task_id, attempt),
3372 crate::core::state::AgentCtxEntry {
3373 view: AgentContextView {
3374 work_dir,
3375 project_root,
3376 ..Default::default()
3377 },
3378 policy: Default::default(),
3379 },
3380 );
3381 })
3382 .await
3383 .expect("seed agent_ctx");
3384 }
3385
3386 /// GH #27 (follow-up to #23): seeds `EngineState.projection_placements`
3387 /// directly — the same snapshot `EngineDispatcher::dispatch` stashes
3388 /// at dispatch time (mirroring [`seed_step_naming`]'s contract) — so
3389 /// these tests can exercise a declared `ProjectionPlacement` without
3390 /// driving a real `Compiler::compile`.
3391 async fn seed_projection_placement(
3392 engine: &Engine,
3393 task_id: &StepId,
3394 placement: crate::core::projection_placement::ProjectionPlacement,
3395 ) {
3396 let task_id = task_id.clone();
3397 let placement = Arc::new(placement);
3398 engine
3399 .with_state("test.seed_projection_placement", move |s| {
3400 s.projection_placements.insert(task_id, placement);
3401 })
3402 .await
3403 .expect("seed projection_placements");
3404 }
3405
3406 /// GH #23 subtask-2: builds a fixture
3407 /// [`crate::core::step_naming::StepNaming`] table declaring `producer`
3408 /// → `canonical` (`AgentMeta.projection_name`), then seeds it into
3409 /// `EngineState.step_namings` for `task_id` — the same snapshot
3410 /// `EngineDispatcher::dispatch` stashes at dispatch time
3411 /// (`blueprint.rs`'s "construct once, read many" contract), stood up
3412 /// here without the full Blueprint-compile path so these tests can
3413 /// exercise the canonical-sink resolution in isolation.
3414 async fn seed_step_naming(engine: &Engine, task_id: &StepId, producer: &str, canonical: &str) {
3415 use crate::blueprint::{
3416 current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
3417 CompilerHints, CompilerStrategy,
3418 };
3419 use crate::core::step_naming::StepNaming;
3420 use mlua_flow_ir::{Expr, Node};
3421
3422 let flow = Node::Step {
3423 ref_: producer.to_string(),
3424 in_: Expr::Path {
3425 at: "$.in".parse().expect("literal test path: $.in"),
3426 },
3427 out: Expr::Path {
3428 at: format!("$.{producer}_out")
3429 .parse()
3430 .expect("literal test path"),
3431 },
3432 };
3433 let bp = Blueprint {
3434 schema_version: current_schema_version(),
3435 id: "sink-canonical-ut".into(),
3436 flow,
3437 agents: vec![AgentDef {
3438 name: producer.to_string(),
3439 kind: AgentKind::RustFn,
3440 spec: serde_json::json!({ "fn_id": producer }),
3441 profile: None,
3442 meta: Some(AgentMeta {
3443 projection_name: Some(canonical.to_string()),
3444 ..Default::default()
3445 }),
3446 runner: None,
3447 runner_ref: None,
3448 verdict: None,
3449 }],
3450 operators: vec![],
3451 metas: vec![],
3452 hints: CompilerHints::default(),
3453 strategy: CompilerStrategy::default(),
3454 metadata: BlueprintMetadata::default(),
3455 spawner_hints: Default::default(),
3456 default_agent_kind: AgentKind::Operator,
3457 default_operator_kind: None,
3458 default_init_ctx: None,
3459 default_agent_ctx: None,
3460 default_context_policy: None,
3461 projection_placement: None,
3462 audits: vec![],
3463 degradation_policy: None,
3464 runners: vec![],
3465 default_runner: None,
3466 };
3467 let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
3468 assert!(warnings.is_empty(), "single-step fixture has no collisions");
3469 let naming = Arc::new(naming);
3470 let task_id = task_id.clone();
3471 engine
3472 .with_state("test.seed_step_naming", move |s| {
3473 s.step_namings.insert(task_id, naming);
3474 })
3475 .await
3476 .expect("seed step_namings");
3477 }
3478
3479 fn final_event(value: Value, ok: bool) -> crate::worker::output::OutputEvent {
3480 crate::worker::output::OutputEvent::Final {
3481 content: crate::worker::output::ContentRef::Inline { value },
3482 ok,
3483 }
3484 }
3485
3486 /// Subtask 4 Test #2: `submit_output`'s `Final` writes
3487 /// `<root>/workspace/tasks/<task_id>/ctx/<agent>.md`, content matching
3488 /// the submitted value.
3489 #[tokio::test]
3490 async fn submit_output_final_materializes_file_when_work_dir_resolved() {
3491 let dir = tempfile::TempDir::new().unwrap();
3492 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
3493 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
3494
3495 engine
3496 .submit_output(
3497 &worker_token,
3498 &task_id,
3499 1,
3500 final_event(serde_json::json!({"plan": "do it"}), true),
3501 )
3502 .await
3503 .expect("submit_output");
3504
3505 let expected_file = dir
3506 .path()
3507 .join("workspace/tasks")
3508 .join(task_id.as_str())
3509 .join("ctx/planner.md");
3510 assert!(
3511 expected_file.exists(),
3512 "materialized submission file missing at {expected_file:?}"
3513 );
3514 let body = std::fs::read_to_string(expected_file).unwrap();
3515 assert!(body.contains(r#""plan": "do it""#), "body: {body}");
3516 }
3517
3518 /// Subtask 4 Test #3: `work_dir` unresolved (no `agent_ctx`
3519 /// snapshot for this `(task_id, attempt)`) — submit still succeeds,
3520 /// fail-open, no file.
3521 #[tokio::test]
3522 async fn submit_output_final_skips_file_when_root_unresolved() {
3523 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
3524 // No seed_agent_context call — root is unresolved.
3525
3526 let result = engine
3527 .submit_output(
3528 &worker_token,
3529 &task_id,
3530 1,
3531 final_event(serde_json::json!("hi"), true),
3532 )
3533 .await;
3534 assert!(
3535 result.is_ok(),
3536 "submit must succeed even with no resolvable root (fail-open, Invariant 1)"
3537 );
3538 }
3539
3540 /// Subtask 4 Test #4 (file half): re-submitting under the same
3541 /// `(task_id, agent)` overwrites the materialized file with the
3542 /// latest value.
3543 #[tokio::test]
3544 async fn resubmit_overwrites_materialized_file_with_latest() {
3545 let dir = tempfile::TempDir::new().unwrap();
3546 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
3547 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
3548
3549 engine
3550 .submit_output(
3551 &worker_token,
3552 &task_id,
3553 1,
3554 final_event(serde_json::json!("first"), true),
3555 )
3556 .await
3557 .expect("first submit");
3558 engine
3559 .submit_output(
3560 &worker_token,
3561 &task_id,
3562 1,
3563 final_event(serde_json::json!("second"), true),
3564 )
3565 .await
3566 .expect("second submit");
3567
3568 let expected_file = dir
3569 .path()
3570 .join("workspace/tasks")
3571 .join(task_id.as_str())
3572 .join("ctx/planner.md");
3573 let body = std::fs::read_to_string(expected_file).unwrap();
3574 assert!(body.contains("second"), "body must reflect latest: {body}");
3575 assert!(
3576 !body.contains("first"),
3577 "body must not carry the stale value: {body}"
3578 );
3579 }
3580
3581 /// GH #27 (follow-up to #23): the byte-compat default
3582 /// `ProjectionPlacement` (`root_preference = WorkDir`) falls back to
3583 /// `project_root` when `work_dir` is absent — the same fallback
3584 /// [`crate::core::projection_placement::ProjectionPlacement::resolve_root`]
3585 /// now performs for every one of the "3 path" call sites, this one
3586 /// exercised at the submit-sink layer.
3587 #[tokio::test]
3588 async fn submit_output_final_falls_back_to_project_root_when_work_dir_absent() {
3589 let dir = tempfile::TempDir::new().unwrap();
3590 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
3591 seed_agent_context_roots(
3592 &engine,
3593 &task_id,
3594 1,
3595 None,
3596 Some(&dir.path().to_string_lossy()),
3597 )
3598 .await;
3599
3600 engine
3601 .submit_output(
3602 &worker_token,
3603 &task_id,
3604 1,
3605 final_event(serde_json::json!({"plan": "via project_root"}), true),
3606 )
3607 .await
3608 .expect("submit_output");
3609
3610 let expected_file = dir
3611 .path()
3612 .join("workspace/tasks")
3613 .join(task_id.as_str())
3614 .join("ctx/planner.md");
3615 assert!(
3616 expected_file.exists(),
3617 "materialized submission file missing at {expected_file:?} \
3618 (work_dir absent must fall back to project_root)"
3619 );
3620 }
3621
3622 /// GH #27 (follow-up to #23): a declared `ProjectionPlacement`
3623 /// (`root_preference = ProjectRoot`, custom `dir_template`) changes
3624 /// BOTH which root is preferred (project_root wins even though
3625 /// work_dir is also present) AND the target directory layout — proof
3626 /// the submit sink consults the snapshotted resolver rather than a
3627 /// hardcoded layout.
3628 #[tokio::test]
3629 async fn submit_output_final_uses_declared_projection_placement() {
3630 let work_dir = tempfile::TempDir::new().unwrap();
3631 let project_root = tempfile::TempDir::new().unwrap();
3632 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
3633 seed_agent_context_roots(
3634 &engine,
3635 &task_id,
3636 1,
3637 Some(&work_dir.path().to_string_lossy()),
3638 Some(&project_root.path().to_string_lossy()),
3639 )
3640 .await;
3641 seed_projection_placement(
3642 &engine,
3643 &task_id,
3644 crate::core::projection_placement::ProjectionPlacement {
3645 root_preference: crate::core::projection_placement::RootPreference::ProjectRoot,
3646 dir_template: "custom/{task_id}/out".to_string(),
3647 },
3648 )
3649 .await;
3650
3651 engine
3652 .submit_output(
3653 &worker_token,
3654 &task_id,
3655 1,
3656 final_event(serde_json::json!({"plan": "via custom placement"}), true),
3657 )
3658 .await
3659 .expect("submit_output");
3660
3661 let expected_file = project_root
3662 .path()
3663 .join("custom")
3664 .join(task_id.as_str())
3665 .join("out/planner.md");
3666 assert!(
3667 expected_file.exists(),
3668 "materialized submission file missing at custom placement target {expected_file:?}"
3669 );
3670 let unexpected_file = work_dir
3671 .path()
3672 .join("workspace/tasks")
3673 .join(task_id.as_str())
3674 .join("ctx/planner.md");
3675 assert!(
3676 !unexpected_file.exists(),
3677 "declared root_preference=ProjectRoot must not fall back to work_dir: {unexpected_file:?}"
3678 );
3679 }
3680
3681 /// Subtask 4 Invariant 3 / crux requirement #3: when
3682 /// [`Engine::set_output_store`] wires a Data-plane [`crate::store::output::OutputStore`],
3683 /// `submit_output`'s `Final` dual-writes into it under
3684 /// `producer_agent = TaskState.spec.agent` — the store becomes
3685 /// queryable via `get_latest_by_name`, independent of whether a root
3686 /// resolved for the file half.
3687 #[tokio::test]
3688 async fn submit_output_final_dual_writes_into_configured_output_store() {
3689 let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
3690 let data_store: Arc<dyn crate::store::output::OutputStore> =
3691 Arc::new(InMemoryOutputStore::new());
3692 engine.set_output_store(data_store.clone());
3693
3694 engine
3695 .submit_output(
3696 &worker_token,
3697 &task_id,
3698 1,
3699 final_event(serde_json::json!({"verdict": "pass"}), true),
3700 )
3701 .await
3702 .expect("submit_output");
3703
3704 let record = data_store
3705 .get_latest_by_name("reviewer")
3706 .await
3707 .expect("dual-written record");
3708 match record.event {
3709 OutputEvent::Final { content, ok } => {
3710 assert!(ok);
3711 match content {
3712 ContentRef::Inline { value } => {
3713 assert_eq!(value, serde_json::json!({"verdict": "pass"}));
3714 }
3715 other => panic!("expected Inline content, got {other:?}"),
3716 }
3717 }
3718 other => panic!("expected Final event, got {other:?}"),
3719 }
3720 }
3721
3722 /// GH #34 subtask-3 gap fix: an `Artifact` event submitted via
3723 /// `submit_output` dual-writes into a wired Data-plane `OutputStore`
3724 /// under its OWN `name`, verbatim — mirrors
3725 /// `submit_output_final_dual_writes_into_configured_output_store`
3726 /// above, but for the `Artifact` variant.
3727 #[tokio::test]
3728 async fn submit_output_artifact_dual_writes_into_configured_output_store() {
3729 let (engine, _op, task_id, worker_token) = seeded_task("echo").await;
3730 let data_store: Arc<dyn crate::store::output::OutputStore> =
3731 Arc::new(InMemoryOutputStore::new());
3732 engine.set_output_store(data_store.clone());
3733
3734 engine
3735 .submit_output(
3736 &worker_token,
3737 &task_id,
3738 1,
3739 OutputEvent::Artifact {
3740 name: "audit:echo".to_string(),
3741 content: ContentRef::Inline {
3742 value: serde_json::json!({"finding": "clean"}),
3743 },
3744 },
3745 )
3746 .await
3747 .expect("submit_output");
3748
3749 let record = data_store
3750 .get_latest_by_name("audit:echo")
3751 .await
3752 .expect("dual-written artifact record");
3753 match record.event {
3754 OutputEvent::Artifact { name, content } => {
3755 assert_eq!(name, "audit:echo");
3756 match content {
3757 ContentRef::Inline { value } => {
3758 assert_eq!(value, serde_json::json!({"finding": "clean"}));
3759 }
3760 other => panic!("expected Inline content, got {other:?}"),
3761 }
3762 }
3763 other => panic!("expected Artifact event, got {other:?}"),
3764 }
3765 // The `Artifact` dual-write must never collide with / overwrite
3766 // the producing step's own `Final` name — `submit_output` never
3767 // materialized a `Final` here, so `"echo"` must stay unresolved.
3768 assert!(
3769 data_store.get_latest_by_name("echo").await.is_err(),
3770 "artifact write must not fabricate a record under the raw producer_agent name"
3771 );
3772 }
3773
3774 /// Invariant 1 (fail-open) for `Artifact`, mirroring
3775 /// `submit_output_final_skips_file_when_root_unresolved`'s Final-side
3776 /// coverage: no `OutputStore` wired at all — submit still succeeds.
3777 #[tokio::test]
3778 async fn submit_output_artifact_is_fail_open_when_no_output_store_configured() {
3779 let (engine, _op, task_id, worker_token) = seeded_task("echo").await;
3780
3781 let result = engine
3782 .submit_output(
3783 &worker_token,
3784 &task_id,
3785 1,
3786 OutputEvent::Artifact {
3787 name: "audit:echo".to_string(),
3788 content: ContentRef::Inline {
3789 value: serde_json::json!("finding"),
3790 },
3791 },
3792 )
3793 .await;
3794 assert!(
3795 result.is_ok(),
3796 "submit must succeed even with no OutputStore wired (fail-open, Invariant 1)"
3797 );
3798 }
3799
3800 /// `submit_worker_result_trusted` (the `/v1/worker/submit` short-handle
3801 /// path) triggers the exact same sink as `submit_output` — parity
3802 /// across both worker-submit entry points.
3803 #[tokio::test]
3804 async fn submit_worker_result_trusted_also_triggers_projection_sink() {
3805 let dir = tempfile::TempDir::new().unwrap();
3806 let (engine, _op, task_id, _worker_token) = seeded_task("planner").await;
3807 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
3808 let data_store: Arc<dyn crate::store::output::OutputStore> =
3809 Arc::new(InMemoryOutputStore::new());
3810 engine.set_output_store(data_store.clone());
3811
3812 engine
3813 .submit_worker_result_trusted(&task_id, 1, serde_json::json!("trusted-value"), true)
3814 .await
3815 .expect("submit_worker_result_trusted");
3816
3817 let expected_file = dir
3818 .path()
3819 .join("workspace/tasks")
3820 .join(task_id.as_str())
3821 .join("ctx/planner.md");
3822 assert!(expected_file.exists());
3823 let record = data_store
3824 .get_latest_by_name("planner")
3825 .await
3826 .expect("dual-written record");
3827 assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
3828 }
3829
3830 /// GH #23 subtask-2 (canonical sink): a declared `projection_name`
3831 /// (`AgentMeta.projection_name`, surfaced via `StepNaming`) redirects
3832 /// `submit_output`'s Final canonical sink — both the Data-plane
3833 /// dual-write name and the materialized file stem resolve to the
3834 /// canonical name, not the raw `producer_agent`.
3835 #[tokio::test]
3836 async fn submit_output_final_uses_canonical_name_when_step_naming_declares_one() {
3837 let dir = tempfile::TempDir::new().unwrap();
3838 let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
3839 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
3840 seed_step_naming(&engine, &task_id, "reviewer", "verdict-final").await;
3841 let data_store: Arc<dyn crate::store::output::OutputStore> =
3842 Arc::new(InMemoryOutputStore::new());
3843 engine.set_output_store(data_store.clone());
3844
3845 engine
3846 .submit_output(
3847 &worker_token,
3848 &task_id,
3849 1,
3850 final_event(serde_json::json!({"verdict": "pass"}), true),
3851 )
3852 .await
3853 .expect("submit_output");
3854
3855 let record = data_store
3856 .get_latest_by_name("verdict-final")
3857 .await
3858 .expect("dual-written record under canonical name");
3859 assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
3860 assert!(
3861 data_store.get_latest_by_name("reviewer").await.is_err(),
3862 "raw producer_agent name must not be written once canonical resolves"
3863 );
3864
3865 let expected_file = dir
3866 .path()
3867 .join("workspace/tasks")
3868 .join(task_id.as_str())
3869 .join("ctx/verdict-final.md");
3870 assert!(
3871 expected_file.exists(),
3872 "materialized file stem must be canonical at {expected_file:?}"
3873 );
3874 }
3875
3876 /// GH #23 subtask-2: no `StepNaming` table snapshotted for this
3877 /// `task_id` (the pre-GH-#23 / no-`with_step_naming` path) is a
3878 /// defensive fail-open — the canonical sink falls back to the raw
3879 /// `producer_agent`, byte-identical to
3880 /// `submit_output_final_dual_writes_into_configured_output_store`
3881 /// above (which never calls `seed_step_naming`).
3882 #[tokio::test]
3883 async fn submit_output_final_falls_back_to_producer_agent_when_no_step_naming_table() {
3884 let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
3885 let data_store: Arc<dyn crate::store::output::OutputStore> =
3886 Arc::new(InMemoryOutputStore::new());
3887 engine.set_output_store(data_store.clone());
3888
3889 engine
3890 .submit_output(
3891 &worker_token,
3892 &task_id,
3893 1,
3894 final_event(serde_json::json!({"verdict": "pass"}), true),
3895 )
3896 .await
3897 .expect("submit_output");
3898
3899 let record = data_store
3900 .get_latest_by_name("reviewer")
3901 .await
3902 .expect("fail-open dual-write under raw producer_agent name");
3903 assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
3904 }
3905
3906 /// GH #23 subtask-2 (Layer 2): `OutputStore::get_latest_by_name_in_run`
3907 /// resolves the value `submit_output` dual-wrote for this exact
3908 /// `(task_id, attempt)` run, independent of `get_latest_by_name`'s
3909 /// cross-Run race (two Runs sharing a producer name never bleed into
3910 /// each other through the Run-scoped accessor).
3911 #[tokio::test]
3912 async fn submit_output_final_is_resolvable_via_run_scoped_lookup() {
3913 let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
3914 let data_store: Arc<dyn crate::store::output::OutputStore> =
3915 Arc::new(InMemoryOutputStore::new());
3916 engine.set_output_store(data_store.clone());
3917
3918 engine
3919 .submit_output(
3920 &worker_token,
3921 &task_id,
3922 1,
3923 final_event(serde_json::json!({"verdict": "pass"}), true),
3924 )
3925 .await
3926 .expect("submit_output");
3927
3928 let record = data_store
3929 .get_latest_by_name_in_run(task_id.as_str(), 1, "reviewer")
3930 .await
3931 .expect("run-scoped lookup resolves the dual-written record");
3932 assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
3933
3934 // A different attempt of the same task must not resolve — the
3935 // Run-scoped lookup does not fall back across attempts.
3936 assert!(
3937 data_store
3938 .get_latest_by_name_in_run(task_id.as_str(), 2, "reviewer")
3939 .await
3940 .is_err(),
3941 "a different attempt must not resolve the same-named record"
3942 );
3943 }
3944}
3945
3946/// GH #36 ST1: named multi-part worker output. Covers (a) the pure
3947/// `fold_final_and_parts` assembly `dispatch_attempt_with`'s Final-pull
3948/// delegates to, (b) `stage_worker_artifact_trusted`'s per-attempt
3949/// isolation on `EngineState.output_store` / `.worker_artifact_names` (the
3950/// same `HashMap<(StepId, u32), _>` key shape `submit_worker_result_trusted`
3951/// uses — a fresh attempt is a fresh key, so nothing to explicitly "clean
3952/// up"), and (c) the allowlist behavior that keeps a non-opt-in `Artifact`
3953/// producer (e.g. `AfterRunAuditMiddleware`) from being folded in.
3954#[cfg(test)]
3955mod named_multi_part_worker_output_tests {
3956 use super::*;
3957 use crate::worker::output::{ContentRef, OutputEvent};
3958
3959 fn artifact(name: &str, value: Value) -> OutputEvent {
3960 OutputEvent::Artifact {
3961 name: name.to_string(),
3962 content: ContentRef::Inline { value },
3963 }
3964 }
3965
3966 fn final_ev(value: Value, ok: bool) -> OutputEvent {
3967 OutputEvent::Final {
3968 content: ContentRef::Inline { value },
3969 ok,
3970 }
3971 }
3972
3973 fn names(list: &[&str]) -> Vec<String> {
3974 list.iter().map(|s| s.to_string()).collect()
3975 }
3976
3977 /// Two staged parts (both in `staged_names`) + a `Final` fold into
3978 /// `{"out", "parts"}`, each value carried through verbatim.
3979 #[test]
3980 fn fold_final_and_parts_assembles_out_and_parts_shape() {
3981 let tail = vec![
3982 artifact("summary", serde_json::json!("the summary")),
3983 artifact("diff", serde_json::json!({"lines": 3})),
3984 final_ev(serde_json::json!("final text"), true),
3985 ];
3986 let staged = names(&["summary", "diff"]);
3987 let (value, ok) = fold_final_and_parts(&tail, &staged).expect("Final present");
3988 assert!(ok);
3989 assert_eq!(
3990 value,
3991 serde_json::json!({
3992 "out": "final text",
3993 "parts": {
3994 "summary": "the summary",
3995 "diff": {"lines": 3},
3996 }
3997 })
3998 );
3999 }
4000
4001 /// Zero staged parts: the value is exactly the plain `Final` value — no
4002 /// `{"out", "parts"}` wrapping. This is the back-compat guarantee (GH
4003 /// #36 must not change the shape for a worker that never POSTs to
4004 /// `/v1/worker/artifact`).
4005 #[test]
4006 fn fold_final_and_parts_with_no_parts_returns_plain_final_value() {
4007 let tail = vec![final_ev(serde_json::json!("plain value"), true)];
4008 let (value, ok) = fold_final_and_parts(&tail, &[]).expect("Final present");
4009 assert!(ok);
4010 assert_eq!(value, serde_json::json!("plain value"));
4011 }
4012
4013 /// The same staged part `name` appearing twice in one attempt: the
4014 /// LATER (tail-order) value wins — `parts` is a `Map`, not an
4015 /// accumulating list.
4016 #[test]
4017 fn fold_final_and_parts_same_name_twice_last_write_wins() {
4018 let tail = vec![
4019 artifact("a", serde_json::json!("first")),
4020 artifact("a", serde_json::json!("second")),
4021 final_ev(serde_json::json!("f"), true),
4022 ];
4023 let staged = names(&["a"]);
4024 let (value, _ok) = fold_final_and_parts(&tail, &staged).expect("Final present");
4025 assert_eq!(
4026 value,
4027 serde_json::json!({"out": "f", "parts": {"a": "second"}})
4028 );
4029 }
4030
4031 /// No `Final` anywhere in the tail (only staged parts, e.g. the worker
4032 /// crashed before submitting) — `None`, the caller's pre-existing "no
4033 /// Final in output_tail" error path.
4034 #[test]
4035 fn fold_final_and_parts_returns_none_when_no_final_present() {
4036 let tail = vec![artifact("a", serde_json::json!("v"))];
4037 let staged = names(&["a"]);
4038 assert!(fold_final_and_parts(&tail, &staged).is_none());
4039 }
4040
4041 /// An `Artifact` on the tail whose name is NOT in `staged_names` (e.g.
4042 /// `AfterRunAuditMiddleware`'s `"audit:<step_ref>"` sidecar finding on
4043 /// an audited step's own tail) must NOT be folded into `"parts"` — the
4044 /// value stays the plain `Final` value, exactly the pre-GH-#36
4045 /// behavior for every producer that isn't the worker's own
4046 /// `/v1/worker/artifact` staging. This is the regression this fold was
4047 /// almost shipped without (see `dispatch_attempt_with`'s doc).
4048 #[test]
4049 fn fold_final_and_parts_ignores_artifacts_outside_the_staged_allowlist() {
4050 let tail = vec![
4051 final_ev(serde_json::json!({"echoed": "hi"}), true),
4052 artifact("audit:echo", serde_json::json!({"finding": "clean"})),
4053 ];
4054 // `staged_names` empty: the worker itself never staged anything —
4055 // the audit sidecar Artifact must be ignored.
4056 let (value, ok) = fold_final_and_parts(&tail, &[]).expect("Final present");
4057 assert!(ok);
4058 assert_eq!(value, serde_json::json!({"echoed": "hi"}));
4059 }
4060
4061 /// Mixed tail: one staged (allowlisted) part and one non-staged
4062 /// (audit-style) `Artifact` — only the staged one is folded in.
4063 #[test]
4064 fn fold_final_and_parts_folds_only_the_staged_subset_of_a_mixed_tail() {
4065 let tail = vec![
4066 artifact("summary", serde_json::json!("s")),
4067 artifact("audit:echo", serde_json::json!({"finding": "clean"})),
4068 final_ev(serde_json::json!("f"), true),
4069 ];
4070 let staged = names(&["summary"]);
4071 let (value, _ok) = fold_final_and_parts(&tail, &staged).expect("Final present");
4072 assert_eq!(
4073 value,
4074 serde_json::json!({"out": "f", "parts": {"summary": "s"}})
4075 );
4076 }
4077
4078 /// `stage_worker_artifact_trusted` writes onto the `(task_id, attempt)`
4079 /// key exactly like `submit_worker_result_trusted` does — a part staged
4080 /// under attempt N is invisible to an `output_tail` / allowlist read of
4081 /// attempt N+1 (a fresh attempt starts empty; nothing carries over).
4082 #[tokio::test]
4083 async fn stage_worker_artifact_trusted_is_isolated_per_attempt() {
4084 let engine = Engine::new(EngineCfg::default());
4085 let task_id = StepId::new();
4086
4087 engine
4088 .stage_worker_artifact_trusted(&task_id, 1, "a".to_string(), serde_json::json!("v1"))
4089 .await
4090 .expect("stage attempt 1");
4091
4092 let attempt_1_tail = engine.output_tail(&task_id, 1).await;
4093 assert_eq!(attempt_1_tail.len(), 1);
4094 assert!(matches!(
4095 &attempt_1_tail[0],
4096 OutputEvent::Artifact { name, .. } if name == "a"
4097 ));
4098 assert_eq!(
4099 engine.worker_artifact_names_for(&task_id, 1).await,
4100 vec!["a".to_string()]
4101 );
4102
4103 let attempt_2_tail = engine.output_tail(&task_id, 2).await;
4104 assert!(
4105 attempt_2_tail.is_empty(),
4106 "attempt 2 must not see attempt 1's staged part"
4107 );
4108 assert!(
4109 engine
4110 .worker_artifact_names_for(&task_id, 2)
4111 .await
4112 .is_empty(),
4113 "attempt 2's allowlist must not see attempt 1's staged name"
4114 );
4115 }
4116}
4117
4118// ─── GH #50 (Subtask 2): `Engine::register_verdict_contracts` /
4119// `Engine::verdict_contract_for_task` ────────────────────────────────────
4120#[cfg(test)]
4121mod verdict_contract_registry_tests {
4122 use super::*;
4123
4124 async fn seeded_engine(agent: &str) -> (Engine, StepId) {
4125 let engine = Engine::new(EngineCfg::default());
4126 let op_token = engine
4127 .attach("ut-op", Role::Operator, Duration::from_secs(30))
4128 .await
4129 .expect("attach");
4130 let task_id = engine
4131 .start_task(
4132 &op_token,
4133 TaskSpec {
4134 agent: agent.to_string(),
4135 initial_directive: serde_json::json!("x"),
4136 step_ctx: None,
4137 },
4138 )
4139 .await
4140 .expect("start_task");
4141 (engine, task_id)
4142 }
4143
4144 /// An agent with no registered contract at all → `None` (the opt-in
4145 /// default; every pre-GH-#50 `Engine`).
4146 #[tokio::test]
4147 async fn returns_none_when_no_contract_registered_for_the_agent() {
4148 let (engine, task_id) = seeded_engine("gate").await;
4149 assert_eq!(engine.verdict_contract_for_task(&task_id).await, None);
4150 }
4151
4152 /// A registered contract for the running task's agent is returned
4153 /// verbatim.
4154 #[tokio::test]
4155 async fn returns_the_registered_contract_for_the_running_agent() {
4156 let (engine, task_id) = seeded_engine("gate").await;
4157 let contract = mlua_swarm_schema::VerdictContract {
4158 channel: mlua_swarm_schema::VerdictChannel::Body,
4159 values: vec!["PASS".to_string(), "BLOCKED".to_string()],
4160 };
4161 engine.register_verdict_contracts(HashMap::from([("gate".to_string(), contract.clone())]));
4162 assert_eq!(
4163 engine.verdict_contract_for_task(&task_id).await,
4164 Some(contract)
4165 );
4166 }
4167
4168 /// A registered contract for a DIFFERENT agent name never leaks onto
4169 /// an unrelated task.
4170 #[tokio::test]
4171 async fn does_not_leak_a_contract_registered_for_a_different_agent() {
4172 let (engine, task_id) = seeded_engine("gate").await;
4173 engine.register_verdict_contracts(HashMap::from([(
4174 "other-agent".to_string(),
4175 mlua_swarm_schema::VerdictContract {
4176 channel: mlua_swarm_schema::VerdictChannel::Body,
4177 values: vec!["PASS".to_string()],
4178 },
4179 )]));
4180 assert_eq!(engine.verdict_contract_for_task(&task_id).await, None);
4181 }
4182
4183 /// An unknown `task_id` → `None`, not a panic / error.
4184 #[tokio::test]
4185 async fn returns_none_for_an_unknown_task_id() {
4186 let engine = Engine::new(EngineCfg::default());
4187 let unknown = StepId::new();
4188 assert_eq!(engine.verdict_contract_for_task(&unknown).await, None);
4189 }
4190
4191 /// `register_verdict_contracts` is additive (`HashMap::extend`): a
4192 /// second call registering a DIFFERENT agent does not clobber the
4193 /// first call's entry.
4194 #[tokio::test]
4195 async fn register_verdict_contracts_is_additive_across_calls() {
4196 let (engine, task_id) = seeded_engine("gate").await;
4197 let contract = mlua_swarm_schema::VerdictContract {
4198 channel: mlua_swarm_schema::VerdictChannel::Part,
4199 values: vec!["ALLOW".to_string()],
4200 };
4201 engine.register_verdict_contracts(HashMap::from([("gate".to_string(), contract.clone())]));
4202 engine.register_verdict_contracts(HashMap::from([(
4203 "unrelated-agent".to_string(),
4204 mlua_swarm_schema::VerdictContract {
4205 channel: mlua_swarm_schema::VerdictChannel::Body,
4206 values: vec!["X".to_string()],
4207 },
4208 )]));
4209 assert_eq!(
4210 engine.verdict_contract_for_task(&task_id).await,
4211 Some(contract)
4212 );
4213 }
4214}
4215
4216// ─── GH #51: completion-time verdict-contract enforcement — the shared
4217// `Engine::verdict_contract_completion_check` choke point embedded inside
4218// `submit_worker_result_trusted` / `submit_output`, exercised here at the
4219// `submit_output` level (the WS Operator fallback route's own unit-test
4220// coverage — see `crates/mlua-swarm-server/tests/verdict_contract.rs` for
4221// the HTTP-round-trip coverage of the other 2 routes) ───────────────────
4222#[cfg(test)]
4223mod verdict_contract_completion_tests {
4224 use super::*;
4225
4226 /// Seeds a `Pending` task bound to `agent` and mints a bound
4227 /// `Role::Worker` token for it — the same mint-and-register pattern
4228 /// `initial_directive_value_passthrough_tests::mint_worker_token`
4229 /// uses (duplicated here: that helper is private to its own sibling
4230 /// `#[cfg(test)]` module, not reachable via `super::*` from this one).
4231 async fn seeded_task_with_worker_token(agent: &str) -> (Engine, CapToken, StepId) {
4232 let engine = Engine::new(EngineCfg::default());
4233 let op_token = engine
4234 .attach("ut-op", Role::Operator, Duration::from_secs(30))
4235 .await
4236 .expect("attach");
4237 let task_id = engine
4238 .start_task(
4239 &op_token,
4240 TaskSpec {
4241 agent: agent.to_string(),
4242 initial_directive: serde_json::json!("x"),
4243 step_ctx: None,
4244 },
4245 )
4246 .await
4247 .expect("start_task");
4248 let worker_token = engine.signer().session(
4249 format!("worker-of-{task_id}"),
4250 Role::Worker,
4251 vec!["*".into()],
4252 Duration::from_secs(600),
4253 );
4254 let fp = worker_token.fingerprint();
4255 let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
4256 engine
4257 .with_state("test.mint_worker", move |s| {
4258 s.tokens.insert(fp, record);
4259 })
4260 .await
4261 .expect("mint worker token");
4262 (engine, worker_token, task_id)
4263 }
4264
4265 fn body_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
4266 mlua_swarm_schema::VerdictContract {
4267 channel: mlua_swarm_schema::VerdictChannel::Body,
4268 values: values.iter().map(|v| v.to_string()).collect(),
4269 }
4270 }
4271
4272 fn part_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
4273 mlua_swarm_schema::VerdictContract {
4274 channel: mlua_swarm_schema::VerdictChannel::Part,
4275 values: values.iter().map(|v| v.to_string()).collect(),
4276 }
4277 }
4278
4279 fn final_event(value: Value, ok: bool) -> crate::worker::output::OutputEvent {
4280 crate::worker::output::OutputEvent::Final {
4281 content: crate::worker::output::ContentRef::Inline { value },
4282 ok,
4283 }
4284 }
4285
4286 /// Route 3 (WS Operator fallback, `submit_output` level) — a
4287 /// `channel: "part"` contract's attempt completes via a plain
4288 /// `Final` without ever staging a `"verdict"` artifact: rejected
4289 /// with `EngineError::VerdictPartMissing`, and nothing lands on
4290 /// `output_tail` — the rejected value never reaches the flow ctx.
4291 #[tokio::test]
4292 async fn submit_output_rejects_missing_verdict_part() {
4293 let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
4294 engine.register_verdict_contracts(HashMap::from([(
4295 "gate".to_string(),
4296 part_contract(&["PASS", "BLOCKED"]),
4297 )]));
4298
4299 let err = engine
4300 .submit_output(
4301 &token,
4302 &task_id,
4303 1,
4304 final_event(serde_json::json!("anything"), true),
4305 )
4306 .await
4307 .expect_err("missing staged verdict part must be rejected");
4308 assert!(
4309 matches!(err, EngineError::VerdictPartMissing { .. }),
4310 "unexpected error variant: {err:?}"
4311 );
4312
4313 let tail = engine.output_tail(&task_id, 1).await;
4314 assert!(
4315 !tail
4316 .iter()
4317 .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
4318 "a rejected completion must not write a Final onto output_tail"
4319 );
4320 }
4321
4322 /// Route 3 — a `channel: "part"` contract completes normally when the
4323 /// worker DID stage a matching `"verdict"` artifact first (defense in
4324 /// depth: presence AND membership both hold).
4325 #[tokio::test]
4326 async fn submit_output_accepts_when_verdict_part_is_staged_and_a_member() {
4327 let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
4328 engine.register_verdict_contracts(HashMap::from([(
4329 "gate".to_string(),
4330 part_contract(&["PASS", "BLOCKED"]),
4331 )]));
4332 engine
4333 .stage_worker_artifact_trusted(
4334 &task_id,
4335 1,
4336 "verdict".to_string(),
4337 serde_json::json!("PASS"),
4338 )
4339 .await
4340 .expect("stage verdict part");
4341
4342 engine
4343 .submit_output(
4344 &token,
4345 &task_id,
4346 1,
4347 final_event(serde_json::json!("full report"), true),
4348 )
4349 .await
4350 .expect("staged + member verdict part must be accepted");
4351
4352 let tail = engine.output_tail(&task_id, 1).await;
4353 assert!(
4354 tail.iter()
4355 .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
4356 "an accepted completion must write its Final onto output_tail"
4357 );
4358 }
4359
4360 /// Route 3 — a `channel: "body"` contract's completing value is NOT a
4361 /// member of `values`: rejected with
4362 /// `EngineError::VerdictValueRejected`, no `Final` written.
4363 #[tokio::test]
4364 async fn submit_output_rejects_body_value_outside_contract() {
4365 let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
4366 engine.register_verdict_contracts(HashMap::from([(
4367 "gate".to_string(),
4368 body_contract(&["PASS", "BLOCKED"]),
4369 )]));
4370
4371 let err = engine
4372 .submit_output(
4373 &token,
4374 &task_id,
4375 1,
4376 final_event(serde_json::json!("UNKNOWN"), true),
4377 )
4378 .await
4379 .expect_err("out-of-contract body value must be rejected");
4380 match err {
4381 EngineError::VerdictValueRejected { value, allowed } => {
4382 assert_eq!(value, "UNKNOWN");
4383 assert_eq!(allowed, vec!["PASS".to_string(), "BLOCKED".to_string()]);
4384 }
4385 other => panic!("unexpected error variant: {other:?}"),
4386 }
4387
4388 let tail = engine.output_tail(&task_id, 1).await;
4389 assert!(
4390 !tail
4391 .iter()
4392 .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
4393 "a rejected completion must not write a Final onto output_tail"
4394 );
4395 }
4396
4397 /// `ok=false` bypasses the completion-time check entirely, regardless
4398 /// of channel or membership — the exemption acceptance criterion,
4399 /// exercised at the `submit_output` choke point.
4400 #[tokio::test]
4401 async fn submit_output_ok_false_bypasses_the_check() {
4402 let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
4403 engine.register_verdict_contracts(HashMap::from([(
4404 "gate".to_string(),
4405 body_contract(&["PASS", "BLOCKED"]),
4406 )]));
4407
4408 engine
4409 .submit_output(
4410 &token,
4411 &task_id,
4412 1,
4413 final_event(serde_json::json!("UNKNOWN"), false),
4414 )
4415 .await
4416 .expect("ok=false must bypass the verdict contract check entirely");
4417
4418 let tail = engine.output_tail(&task_id, 1).await;
4419 assert!(
4420 tail.iter()
4421 .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
4422 "an ok=false completion is exempt, not rejected — its Final must still land"
4423 );
4424 }
4425
4426 /// `staged_verdict_value_for` mirrors `fold_final_and_parts`'s
4427 /// last-write-wins semantics: staging `"verdict"` twice within the
4428 /// same attempt returns the LAST value, not the first.
4429 #[tokio::test]
4430 async fn staged_verdict_value_for_is_last_write_wins() {
4431 let (engine, _token, task_id) = seeded_task_with_worker_token("gate").await;
4432 engine
4433 .stage_worker_artifact_trusted(
4434 &task_id,
4435 1,
4436 "verdict".to_string(),
4437 serde_json::json!("PASS"),
4438 )
4439 .await
4440 .expect("stage first verdict part");
4441 engine
4442 .stage_worker_artifact_trusted(
4443 &task_id,
4444 1,
4445 "verdict".to_string(),
4446 serde_json::json!("BLOCKED"),
4447 )
4448 .await
4449 .expect("stage second verdict part");
4450
4451 assert_eq!(
4452 engine.staged_verdict_value_for(&task_id, 1).await,
4453 Some("BLOCKED".to_string())
4454 );
4455 }
4456
4457 /// `staged_verdict_value_for` ignores artifacts staged under any name
4458 /// OTHER than the literal `"verdict"` — mirrors `channel: "part"`
4459 /// contracts only ever addressing that one part.
4460 #[tokio::test]
4461 async fn staged_verdict_value_for_ignores_other_artifact_names() {
4462 let (engine, _token, task_id) = seeded_task_with_worker_token("gate").await;
4463 engine
4464 .stage_worker_artifact_trusted(
4465 &task_id,
4466 1,
4467 "notes".to_string(),
4468 serde_json::json!("irrelevant"),
4469 )
4470 .await
4471 .expect("stage unrelated part");
4472
4473 assert_eq!(engine.staged_verdict_value_for(&task_id, 1).await, None);
4474 }
4475
4476 /// `staged_verdict_value_for` → `None` when nothing was ever staged —
4477 /// the normal case the completion check turns into
4478 /// `EngineError::VerdictPartMissing`.
4479 #[tokio::test]
4480 async fn staged_verdict_value_for_returns_none_when_nothing_staged() {
4481 let (engine, _token, task_id) = seeded_task_with_worker_token("gate").await;
4482 assert_eq!(engine.staged_verdict_value_for(&task_id, 1).await, None);
4483 }
4484}