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}
78
79/// Renders a `TaskSpec.initial_directive` / `EngineState.prompts`
80/// `Value` down to the `String` shape that string-consuming boundaries
81/// require (issue #18). Strings pass through verbatim; anything else
82/// (Object / Array / Number / Bool / Null) is serde-stringified. This
83/// is the single canonical rendering — the coercion that used to sit
84/// inside `EngineDispatcher::dispatch` moved here and is invoked only
85/// at consumer boundaries: `WorkerPayload.prompt` (HTTP
86/// `/v1/worker/prompt`), `WorkerInvocation.prompt` (in-process
87/// spawners), the subprocess spawner's directive arg/stdin, and the
88/// WS Spawn frame text render (`operator_ws::session`). Everything
89/// upstream (Blueprint dispatch → engine state → `fetch_prompt` →
90/// `Operator::execute`) keeps the `Value` end-to-end.
91pub(crate) fn render_directive_to_string(v: &Value) -> String {
92 match v {
93 Value::String(s) => s.clone(),
94 other => other.to_string(),
95 }
96}
97
98impl Engine {
99 /// Backwards-compatible constructor that starts the engine without a
100 /// layer registry, preserving the signature already used by ~88
101 /// existing call sites. Use this when automatic middleware wrapping
102 /// at bind time is not needed. Callers such as `mlua-swarm-server` go through
103 /// `new_with_layers(cfg, registry)` to enable the hint-resolution path.
104 pub fn new(cfg: EngineCfg) -> Self {
105 Self::new_with_layers(cfg, crate::middleware::LayerRegistry::new())
106 }
107
108 /// Construct an `Engine` with an explicit `LayerRegistry`, enabling
109 /// hint-resolution: `spawner_hints.layers` declared on a `Blueprint`
110 /// are resolved against this registry when the spawner stack is bound
111 /// at `service::linker::link` time.
112 pub fn new_with_layers(
113 cfg: EngineCfg,
114 layer_registry: crate::middleware::LayerRegistry,
115 ) -> Self {
116 let (event_tx, _) = broadcast::channel(256);
117 let signer = TokenSigner::new(&cfg.token_secret);
118 Self {
119 inner: Arc::new(EngineInner {
120 state: Mutex::new(EngineState::new()),
121 cfg,
122 signer,
123 gate: default_role_verb_table(),
124 event_tx,
125 senior_bridges: tokio::sync::RwLock::new(HashMap::new()),
126 spawn_hooks: tokio::sync::RwLock::new(HashMap::new()),
127 operators: tokio::sync::RwLock::new(HashMap::new()),
128 layer_registry,
129 data_store: std::sync::RwLock::new(None),
130 }),
131 }
132 }
133
134 /// Rebuild this `Engine` with a different `RoleVerbGate`. The gate is
135 /// treated as fixed-at-build-time, so this constructs a fresh
136 /// `EngineInner` (fresh empty `EngineState`) rather than mutating in
137 /// place — mainly a testing convenience for swapping gate rules.
138 pub fn with_gate(self, gate: RoleVerbGate) -> Self {
139 // The gate is fixed at build time — the intent is to build a fresh
140 // instance rather than mutating in place. As a testing convenience we
141 // do allow swapping the inner Arc. Simpler form: just rebuild
142 // Arc<EngineInner>.
143 let inner = Arc::new(EngineInner {
144 state: Mutex::new(EngineState::new()),
145 cfg: self.inner.cfg.clone(),
146 signer: self.inner.signer.clone(),
147 gate,
148 event_tx: self.inner.event_tx.clone(),
149 senior_bridges: tokio::sync::RwLock::new(HashMap::new()),
150 spawn_hooks: tokio::sync::RwLock::new(HashMap::new()),
151 operators: tokio::sync::RwLock::new(HashMap::new()),
152 layer_registry: self.inner.layer_registry.clone(),
153 data_store: std::sync::RwLock::new(None),
154 });
155 Self { inner }
156 }
157
158 // ═══════════════════════════════════════════════════════════════════════
159 // Accessors. Production code drives execution through compile +
160 // `service::linker::link` + `dispatch_attempt_with(spawner)` inside
161 // `TaskLaunchService`; `Engine` itself is a pure execution surface — it
162 // does not own a BlueprintStore / EnhanceAdapter / Compiler, nor a
163 // global spawner (the spawner is carried per-request, never stashed on
164 // the engine).
165 // ═══════════════════════════════════════════════════════════════════════
166
167 /// Access the `EngineCfg` this engine was built with.
168 pub fn cfg(&self) -> &EngineCfg {
169 &self.inner.cfg
170 }
171
172 /// Expose the internal `LayerRegistry` — used when deriving a
173 /// sub-engine that needs the same registry re-injected. The
174 /// per-request sub-engine in `mlua-swarm-server` reads the parent engine's
175 /// registry through this accessor and passes it to
176 /// `Engine::new_with_layers(cfg, parent.layer_registry().clone())`.
177 pub fn layer_registry(&self) -> &crate::middleware::LayerRegistry {
178 &self.inner.layer_registry
179 }
180
181 /// Access the `TokenSigner` used to mint/verify `CapToken`s.
182 pub fn signer(&self) -> &TokenSigner {
183 &self.inner.signer
184 }
185
186 /// Clone a handle to the process-wide `Event` broadcast sender. Prefer
187 /// `subscribe` for a ready-to-use receiver.
188 pub fn event_tx(&self) -> broadcast::Sender<Event> {
189 self.inner.event_tx.clone()
190 }
191
192 /// Subscribe to the engine's `Event` broadcast stream.
193 pub fn subscribe(&self) -> EventStream {
194 self.inner.event_tx.subscribe()
195 }
196
197 /// Wires the Data-plane [`crate::store::output::OutputStore`] backend
198 /// used by `submit_output` / `submit_worker_result_trusted`'s
199 /// submit-time projection sink (subtask-4 / ST2 rework — see
200 /// `submit_output`'s doc). Synchronous (a plain `std::sync::RwLock`
201 /// write) so a caller can wire it up at boot from a non-`async`
202 /// context (`mlua-swarm-server`'s router builder passes the same
203 /// `Arc` it hands to its `AppState.data_store`, so `POST
204 /// /v1/data/emit` and every worker's ordinary `/v1/worker/submit` land
205 /// in the one store). Calling this more than once replaces the
206 /// previous backend; not calling it at all (the default) preserves
207 /// pre-subtask-4 behavior exactly — `submit_output` only touches the
208 /// Domain-plane `EngineState.output_store` HashMap.
209 pub fn set_output_store(&self, store: Arc<dyn crate::store::output::OutputStore>) {
210 let mut guard = self
211 .inner
212 .data_store
213 .write()
214 .unwrap_or_else(|poisoned| poisoned.into_inner());
215 *guard = Some(store);
216 }
217
218 /// Clones the currently-wired Data-plane store handle, if any. Kept
219 /// private and side-effect-free (no lock held past this call) —
220 /// callers (`materialize_final_submission`) do their actual `.append`
221 /// work outside of any lock.
222 fn output_store_backend(&self) -> Option<Arc<dyn crate::store::output::OutputStore>> {
223 self.inner
224 .data_store
225 .read()
226 .unwrap_or_else(|poisoned| poisoned.into_inner())
227 .clone()
228 }
229
230 // ═══════════════════════════════════════════════════════════════════════
231 // §7 with_state — single Mutex + R1-R4 (try_lock + bounded retry + max-hold panic)
232 // ═══════════════════════════════════════════════════════════════════════
233
234 /// The closure is a **sync** `FnOnce` — you cannot pass an async
235 /// closure, which enforces R3 at the type level. Exceeding `max_hold`
236 /// panics so that R4 violations surface immediately.
237 pub async fn with_state<F, R>(&self, op: &'static str, f: F) -> Result<R, EngineError>
238 where
239 F: FnOnce(&mut EngineState) -> R,
240 {
241 let cfg = &self.inner.cfg;
242
243 // R2: try_lock + bounded retry
244 let mut guard_opt = None;
245 for attempt in 0..=cfg.max_retry {
246 match self.inner.state.try_lock() {
247 Ok(g) => {
248 guard_opt = Some(g);
249 break;
250 }
251 Err(_) if cfg.try_only => return Err(EngineError::LockBusy(op)),
252 Err(_) => {
253 let backoff = cfg.backoff_ms_step * (attempt as u64 + 1);
254 tokio::time::sleep(Duration::from_millis(backoff)).await;
255 }
256 }
257 }
258 let mut guard = guard_opt.ok_or(EngineError::LockBusyAfterRetry(op))?;
259
260 // R4: max_hold guard
261 let start = Instant::now();
262 let result = f(&mut guard);
263 let elapsed_ms = start.elapsed().as_millis();
264 drop(guard);
265
266 if elapsed_ms > cfg.max_hold_ms {
267 panic!(
268 "Engine.with_state('{op}') held {elapsed_ms}ms > max {}ms — suspected R3 violation (long op inside lock)",
269 cfg.max_hold_ms
270 );
271 }
272 Ok(result)
273 }
274
275 // ═══════════════════════════════════════════════════════════════════════
276 // Token verify (= sig + expire + gate + uses_left)
277 // ═══════════════════════════════════════════════════════════════════════
278
279 /// Four steps: (1) signature verify, (2) expiry check, (3) role × verb
280 /// gate, (4) `uses_left` consume.
281 pub async fn verify_token(&self, token: &CapToken, verb: Verb) -> Result<(), EngineError> {
282 // (1) sig
283 if !self.inner.signer.verify_sig(token) {
284 return Err(EngineError::BadSignature);
285 }
286 // (2) expire
287 if token.is_expired(now_unix()) {
288 return Err(EngineError::TokenExpired);
289 }
290 // (3) role × verb gate
291 if !self.inner.gate.is_allowed(token.role, verb) {
292 return Err(EngineError::RoleViolation {
293 role: token.role,
294 verb,
295 });
296 }
297 // (4) server-side uses_left consume
298 let fp = token.fingerprint();
299 self.with_state("token.consume", move |s| {
300 let rec = s
301 .tokens
302 .get_mut(&fp)
303 .ok_or_else(|| EngineError::TokenNotFound(fp.clone()))?;
304 rec.consume()
305 .map_err(|_: crate::core::state::CapTokenConsumeError| {
306 EngineError::TokenUsesExhausted
307 })?;
308 Ok::<(), EngineError>(())
309 })
310 .await??;
311 Ok(())
312 }
313
314 /// `verify_token` plus the **task-ownership gate**.
315 ///
316 /// When a Worker-role token calls a state-touch verb (`fetch_prompt` /
317 /// `post_result` / `read_task_state` / `cancel_task` / `poll_task`),
318 /// the gate checks that `CapTokenRecord.task_id` matches the argument
319 /// `task_id`; a mismatch returns `EngineError::TokenTaskMismatch`.
320 /// Operator / Senior / Observer tokens are outside the ownership gate
321 /// and may touch any task.
322 ///
323 /// **Verbs exempt from the gate.** `start_task` and `dispatch_attempt`
324 /// stay outside so recursive swarming keeps working; depth is capped
325 /// by `max_spawn_depth`.
326 pub async fn verify_token_for_task(
327 &self,
328 token: &CapToken,
329 verb: Verb,
330 task_id: &StepId,
331 ) -> Result<(), EngineError> {
332 self.verify_token(token, verb).await?;
333 if token.role != Role::Worker {
334 return Ok(());
335 }
336 let fp = token.fingerprint();
337 let arg_tid = task_id.clone();
338 self.with_state("token.ownership_gate", move |s| {
339 let bound = s.tokens.get(&fp).and_then(|r| r.task_id.as_ref()).cloned();
340 match bound {
341 Some(t) if t == arg_tid => Ok(()),
342 Some(t) => Err(EngineError::TokenTaskMismatch {
343 bound: t.into_string(),
344 arg: arg_tid.into_string(),
345 }),
346 None => Err(EngineError::TokenNotFound(fp.clone())),
347 }
348 })
349 .await??;
350 Ok(())
351 }
352
353 /// Resolve the bound `task_id` from a Worker-role token. Used on the
354 /// simple `/v1/worker/submit` endpoint, where the worker POSTs with a
355 /// token but no `task_id`. Returns `Err` if the token role is not
356 /// Worker, or if no bound task is set.
357 pub async fn task_id_from_token(&self, token: &CapToken) -> Result<StepId, EngineError> {
358 if token.role != Role::Worker {
359 return Err(EngineError::RoleViolation {
360 role: token.role,
361 verb: Verb::PostResult,
362 });
363 }
364 let fp = token.fingerprint();
365 self.with_state("task_id_from_token", move |s| {
366 s.tokens
367 .get(&fp)
368 .and_then(|r| r.task_id.as_ref())
369 .cloned()
370 .ok_or_else(|| EngineError::TokenNotFound(fp.clone()))
371 })
372 .await?
373 }
374
375 /// Resolve a short worker handle (`wh-XXXXXXXX`) to the bound
376 /// `task_id`. Used on `/v1/worker/submit` when the Bearer is a short
377 /// handle string rather than a full `CapToken` JSON. A missing entry
378 /// returns `TokenNotFound`, i.e. "the handle is not in the store".
379 pub async fn task_id_from_handle(&self, handle: &str) -> Result<StepId, EngineError> {
380 let h = handle.to_string();
381 self.with_state("task_id_from_handle", move |s| {
382 let fp = s
383 .worker_handles
384 .get(&h)
385 .cloned()
386 .ok_or_else(|| EngineError::TokenNotFound(format!("handle={h}")))?;
387 s.tokens
388 .get(&fp)
389 .and_then(|r| r.task_id.as_ref())
390 .cloned()
391 .ok_or_else(|| EngineError::TokenNotFound(format!("fp={fp}")))
392 })
393 .await?
394 }
395
396 /// Submit a worker result via a short handle. Skips token verification
397 /// and updates `output_tail` `Final` + `task.last_result` directly in
398 /// a thin path. The caller is expected to have already resolved
399 /// `task_id` via `task_id_from_handle` — the handle's presence in
400 /// `worker_handles` means it was minted server-side and is therefore
401 /// trusted.
402 pub async fn submit_worker_result_trusted(
403 &self,
404 task_id: &StepId,
405 attempt: u32,
406 value: Value,
407 ok: bool,
408 ) -> Result<(), EngineError> {
409 let task_id_for_apply = task_id.clone();
410 let value_for_event = value.clone();
411 self.with_state("submit_worker_result_trusted.output", move |s| {
412 let ev = crate::worker::output::OutputEvent::Final {
413 content: crate::worker::output::ContentRef::Inline {
414 value: value_for_event,
415 },
416 ok,
417 };
418 s.output_store
419 .entry((task_id_for_apply.clone(), attempt))
420 .or_default()
421 .push(ev.clone());
422 s.push_event(crate::core::state::Event::WorkerOutput {
423 task_id: task_id_for_apply,
424 attempt,
425 event: ev,
426 });
427 })
428 .await?;
429 let task_id_for_result = task_id.clone();
430 let value_for_result = value.clone();
431 self.with_state("submit_worker_result_trusted.last_result", move |s| {
432 if let Some(t) = s.tasks.get_mut(&task_id_for_result) {
433 t.last_result = Some(value_for_result);
434 t.updated_at = now_unix();
435 }
436 })
437 .await?;
438 // subtask-4 / ST2 rework: this path always submits a `Final` (there
439 // is no other event kind on `/v1/worker/submit`), so the
440 // submit-time projection sink always fires — see
441 // `materialize_final_submission`'s doc and `submit_output`'s
442 // Invariants (fail-open, never turns a would-have-succeeded submit
443 // into a failure).
444 let content = crate::worker::output::ContentRef::Inline { value };
445 self.materialize_final_submission(task_id, attempt, &content, ok)
446 .await;
447 Ok(())
448 }
449
450 /// Mint a short handle and register it in the `worker_handles` map.
451 /// Called immediately after the worker-token mint inside
452 /// `dispatch_attempt_with`, and issues a handle bound to the same
453 /// token fingerprint. Format is `wh-<8 hex chars>` (11 chars total),
454 /// designed to remove the base64 copy-paste failure mode.
455 async fn mint_worker_handle(&self, worker_fp: String) -> Result<String, EngineError> {
456 // The handle is a sole bearer secret on the `/v1/worker/submit`
457 // short-handle path (`submit_worker_result_trusted` skips token
458 // verification), so it must be unguessable — OS RNG, not the
459 // predictable uid counter. 8 hex chars (~4B entropy) keeps the
460 // documented `wh-<8 hex>` wire shape; collision between live
461 // handles is negligible at in-process handle counts.
462 let short = crate::types::secure_hex(4);
463 let handle = format!("wh-{short}");
464 let h = handle.clone();
465 self.with_state("mint_worker_handle", move |s| {
466 s.worker_handles.insert(h, worker_fp);
467 })
468 .await?;
469 Ok(handle)
470 }
471
472 // ═══════════════════════════════════════════════════════════════════════
473 // Session API
474 // ═══════════════════════════════════════════════════════════════════════
475
476 /// Attach a new session with default `OperatorInfo` (`Automate`, no
477 /// bridges/hooks). Shorthand for `attach_with(.., OperatorInfo::default())`.
478 pub async fn attach(
479 &self,
480 operator_id: impl Into<String>,
481 role: Role,
482 ttl: Duration,
483 ) -> Result<CapToken, EngineError> {
484 self.attach_with(
485 operator_id,
486 role,
487 ttl,
488 crate::core::ctx::OperatorInfo::default(),
489 )
490 .await
491 }
492
493 // ═══════════════════════════════════════════════════════════════════════
494 // BridgeRegistry API.
495 // ═══════════════════════════════════════════════════════════════════════
496
497 /// Register a `SeniorBridge` under a name. An existing entry with the
498 /// same name is overwritten. On the persisted-session reattach path,
499 /// the caller re-registers under the same ID beforehand and the
500 /// bridge becomes effective again.
501 pub async fn register_senior_bridge(
502 &self,
503 id: impl Into<String>,
504 bridge: Arc<dyn SeniorBridge>,
505 ) {
506 self.inner
507 .senior_bridges
508 .write()
509 .await
510 .insert(id.into(), bridge);
511 }
512
513 /// Register a `SpawnHook` under a name. An existing entry with the
514 /// same name is overwritten.
515 pub async fn register_spawn_hook(&self, id: impl Into<String>, hook: Arc<dyn SpawnHook>) {
516 self.inner.spawn_hooks.write().await.insert(id.into(), hook);
517 }
518
519 /// Register an `Operator` (a spawn-body backend) under a name. An
520 /// existing entry with the same name is overwritten.
521 /// `OperatorDelegateMiddleware` looks this up via `ctx` and, when
522 /// `kind = MainAi` / `Composite`, bypasses `inner.spawn` and calls
523 /// `operator.execute` instead.
524 pub async fn register_operator(
525 &self,
526 id: impl Into<String>,
527 operator: Arc<dyn crate::operator::Operator>,
528 ) {
529 self.inner
530 .operators
531 .write()
532 .await
533 .insert(id.into(), operator);
534 }
535
536 /// Unregister a `SeniorBridge` by name (e.g. on WebSocket disconnect
537 /// or explicit teardown). A missing ID is a no-op.
538 pub async fn unregister_senior_bridge(&self, id: &str) {
539 self.inner.senior_bridges.write().await.remove(id);
540 }
541
542 /// Unregister a `SpawnHook` by name. A missing ID is a no-op.
543 pub async fn unregister_spawn_hook(&self, id: &str) {
544 self.inner.spawn_hooks.write().await.remove(id);
545 }
546
547 /// Unregister an `Operator` backend by name. A missing ID is a no-op.
548 pub async fn unregister_operator(&self, id: &str) {
549 self.inner.operators.write().await.remove(id);
550 }
551
552 /// Snapshot the list of registered `SpawnHook` IDs (for test
553 /// observation and debugging).
554 pub async fn list_spawn_hook_ids(&self) -> Vec<String> {
555 self.inner
556 .spawn_hooks
557 .read()
558 .await
559 .keys()
560 .cloned()
561 .collect()
562 }
563
564 /// Snapshot the list of registered `SeniorBridge` IDs.
565 pub async fn list_senior_bridge_ids(&self) -> Vec<String> {
566 self.inner
567 .senior_bridges
568 .read()
569 .await
570 .keys()
571 .cloned()
572 .collect()
573 }
574
575 /// Snapshot the list of registered `Operator` IDs.
576 pub async fn list_operator_ids(&self) -> Vec<String> {
577 self.inner.operators.read().await.keys().cloned().collect()
578 }
579
580 /// Attach specifying IDs directly. The caller is expected to have
581 /// pre-registered them via `register_senior_bridge` /
582 /// `register_spawn_hook` / `register_operator`. This is the canonical
583 /// path when persistence is in play.
584 ///
585 /// `kind` is the "Runtime Global" tier of the `OperatorKind` cascade
586 /// (stored verbatim on `OperatorSession.operator_kind`): `Some(_)` is
587 /// an explicit request (including `Some(OperatorKind::Automate)`) that
588 /// outranks the BP-level tiers; `None` leaves it unspecified so the
589 /// BP-level tiers / final default decide. See
590 /// `crate::core::ctx::collapse_operator_kind`.
591 #[allow(clippy::too_many_arguments)]
592 pub async fn attach_with_ids(
593 &self,
594 operator_id: impl Into<String>,
595 role: Role,
596 ttl: Duration,
597 kind: Option<OperatorKind>,
598 bridge_id: Option<String>,
599 hook_id: Option<String>,
600 operator_backend_id: Option<String>,
601 operator_kind_overrides: HashMap<String, OperatorKind>,
602 bp_agent_kinds: HashMap<String, OperatorKind>,
603 bp_global_kind: Option<OperatorKind>,
604 ) -> Result<CapToken, EngineError> {
605 let operator_id = operator_id.into();
606 let token = self
607 .inner
608 .signer
609 .session(operator_id.clone(), role, vec!["*".into()], ttl);
610 let session_id = SessionId::new();
611 let fp = token.fingerprint();
612 let now = now_unix();
613 let token_for_store = token.clone();
614
615 self.with_state("attach_with_ids", |s| {
616 s.tokens
617 .insert(fp.clone(), CapTokenRecord::from_token(token_for_store));
618 s.sessions.insert(
619 session_id.clone(),
620 OperatorSession {
621 id: session_id.clone(),
622 operator_id: operator_id.clone(),
623 role,
624 attached_at: now,
625 last_seen: now,
626 attached: true,
627 owned_task_ids: Vec::new(),
628 token_fp: fp.clone(),
629 operator_kind: kind,
630 runtime_agent_kinds: operator_kind_overrides,
631 bp_agent_kinds,
632 bp_global_kind,
633 bridge_id,
634 hook_id,
635 operator_backend_id,
636 },
637 );
638 s.push_event(Event::SessionAttached {
639 session_id: session_id.clone(),
640 role,
641 });
642 })
643 .await?;
644
645 let _ = self
646 .inner
647 .event_tx
648 .send(Event::SessionAttached { session_id, role });
649 Ok(token)
650 }
651
652 /// Build an `OperatorInfo` by looking up the session's registered IDs
653 /// on the `BridgeRegistry`, plus resolving the 4-tier `OperatorKind`
654 /// cascade for `agent_name` via `crate::core::ctx::collapse_operator_kind`.
655 /// Used when `dispatch_attempt` injects `Ctx`. An unresolved ID
656 /// (nothing registered) is silently `None` — the bridge / hook simply
657 /// does not fire and the default behaviour applies.
658 async fn resolve_operator_info(
659 &self,
660 session: &OperatorSession,
661 agent_name: &str,
662 ) -> OperatorInfo {
663 let senior_bridge = if let Some(id) = &session.bridge_id {
664 self.inner.senior_bridges.read().await.get(id).cloned()
665 } else {
666 None
667 };
668 let spawn_hook = if let Some(id) = &session.hook_id {
669 self.inner.spawn_hooks.read().await.get(id).cloned()
670 } else {
671 None
672 };
673 let operator = if let Some(id) = &session.operator_backend_id {
674 self.inner.operators.read().await.get(id).cloned()
675 } else {
676 None
677 };
678 let runtime_agent = session.runtime_agent_kinds.get(agent_name).copied();
679 // "Runtime Global" tier: `Some(_)` is always an explicit request
680 // (see the field doc on `OperatorSession.operator_kind`).
681 let runtime_global = session.operator_kind;
682 let bp_agent = session.bp_agent_kinds.get(agent_name).copied();
683 let bp_global = session.bp_global_kind;
684 let kind = crate::core::ctx::collapse_operator_kind(
685 runtime_agent,
686 runtime_global,
687 bp_agent,
688 bp_global,
689 );
690 OperatorInfo {
691 kind,
692 id: session.operator_id.clone(),
693 senior_bridge,
694 spawn_hook,
695 operator,
696 }
697 }
698
699 /// Convenience attach that takes an `OperatorInfo` (three
700 /// `Arc<dyn ...>` fields plus `kind`) **inline**.
701 ///
702 /// # Pipeline
703 ///
704 /// Each `Arc<dyn ...>` is auto-registered on the engine's registry
705 /// under a synthetic ID (`br-<hex>` / `hk-<hex>` / `ob-<hex>`), and
706 /// the session stores that synthetic ID. Subsequent `dispatch_attempt`
707 /// calls rebuild the `Arc`s from those IDs via
708 /// `resolve_operator_info`, and the three middlewares fire as usual.
709 ///
710 /// # ⚠ Non-persisted sessions only
711 ///
712 /// Because this API takes inline `Arc`s, the reattach path after
713 /// session persistence cannot rebuild them — the synthetic IDs are
714 /// not present in a freshly started process's registry. If you need
715 /// persistence, use [`Self::attach_with_ids`] with `register_*` calls
716 /// beforehand to go through **named IDs** instead.
717 ///
718 /// Handy for tests and short-lived in-process sessions. Production
719 /// WebSocket callbacks and the like should prefer `attach_with_ids`
720 /// as the canonical path.
721 pub async fn attach_with(
722 &self,
723 operator_id: impl Into<String>,
724 role: Role,
725 ttl: Duration,
726 operator_info: crate::core::ctx::OperatorInfo,
727 ) -> Result<CapToken, EngineError> {
728 let operator_id = operator_id.into();
729 // The caller always hands in a fully-formed `OperatorInfo`
730 // (including its `kind`), so it is stored as an explicit "Runtime
731 // Global" tier request (`Some(kind)`) — this path never persists
732 // BP-level tiers (both stay empty below), so `Some(kind)` resolves
733 // to the same `kind` at dispatch either way; see
734 // `OperatorSession.operator_kind` doc.
735 let kind = operator_info.kind;
736 // BridgeRegistry auto-register: when the caller hands in an
737 // `Arc<dyn>` directly, register it under a synthesised ID (the inline
738 // path aware of persistence). Callers who want to pre-register with a
739 // named ID should use `register_senior_bridge` / `register_spawn_hook`
740 // + `attach_with_ids`.
741 let bridge_id = if let Some(bridge) = operator_info.senior_bridge.clone() {
742 let id = format!("br-{}", crate::types::uid_hex(8));
743 self.inner
744 .senior_bridges
745 .write()
746 .await
747 .insert(id.clone(), bridge);
748 Some(id)
749 } else {
750 None
751 };
752 let hook_id = if let Some(hook) = operator_info.spawn_hook.clone() {
753 let id = format!("hk-{}", crate::types::uid_hex(8));
754 self.inner
755 .spawn_hooks
756 .write()
757 .await
758 .insert(id.clone(), hook);
759 Some(id)
760 } else {
761 None
762 };
763 let operator_backend_id = if let Some(operator) = operator_info.operator.clone() {
764 // `ob-` = operator-backend registry id. Renamed from `op-` in the
765 // issue #11 prefix reconciliation: `op-` used to collide with the
766 // WS operator sid shape (now unified into `S-<hex>` anyway), and a
767 // shared prefix across two unrelated registries made log filtering
768 // by prefix silently ambiguous.
769 let id = format!("ob-{}", crate::types::uid_hex(8));
770 self.inner
771 .operators
772 .write()
773 .await
774 .insert(id.clone(), operator);
775 Some(id)
776 } else {
777 None
778 };
779
780 let token = self
781 .inner
782 .signer
783 .session(operator_id.clone(), role, vec!["*".into()], ttl);
784 let session_id = SessionId::new();
785 let fp = token.fingerprint();
786 let now = now_unix();
787 let token_for_store = token.clone();
788
789 self.with_state("attach_with", |s| {
790 s.tokens
791 .insert(fp.clone(), CapTokenRecord::from_token(token_for_store));
792 s.sessions.insert(
793 session_id.clone(),
794 OperatorSession {
795 id: session_id.clone(),
796 operator_id,
797 role,
798 attached_at: now,
799 last_seen: now,
800 attached: true,
801 owned_task_ids: Vec::new(),
802 token_fp: fp.clone(),
803 operator_kind: Some(kind),
804 runtime_agent_kinds: HashMap::new(),
805 bp_agent_kinds: HashMap::new(),
806 bp_global_kind: None,
807 bridge_id,
808 hook_id,
809 operator_backend_id,
810 },
811 );
812 s.push_event(Event::SessionAttached {
813 session_id: session_id.clone(),
814 role,
815 });
816 })
817 .await?;
818
819 let _ = self
820 .inner
821 .event_tx
822 .send(Event::SessionAttached { session_id, role });
823 Ok(token)
824 }
825
826 /// Mark the session bound to `token` as detached (`attached = false`).
827 /// Tasks are left in place — a later `attach`/`attach_with_ids` call
828 /// carrying the same registered bridge/hook IDs can pick them back up.
829 pub async fn detach(&self, token: &CapToken) -> Result<(), EngineError> {
830 self.verify_token(token, Verb::DetachSession).await?;
831 let fp = token.fingerprint();
832 self.with_state("detach", move |s| {
833 let sid = s
834 .sessions
835 .iter()
836 .find(|(_, sess)| sess.token_fp == fp)
837 .map(|(id, _)| id.clone());
838 if let Some(sid) = sid {
839 if let Some(sess) = s.sessions.get_mut(&sid) {
840 sess.attached = false;
841 }
842 s.push_event(Event::SessionDetached {
843 session_id: sid.clone(),
844 });
845 let _ = sid;
846 }
847 })
848 .await?;
849 Ok(())
850 }
851
852 /// Refresh the session's `last_seen` timestamp and mark it `attached`.
853 /// Called periodically by an attached client to avoid being flipped to
854 /// detached by `start_detach_loop`.
855 pub async fn heartbeat(&self, token: &CapToken) -> Result<(), EngineError> {
856 self.verify_token(token, Verb::Heartbeat).await?;
857 let now = now_unix();
858 let fp = token.fingerprint();
859 self.with_state("heartbeat", move |s| {
860 if let Some(sess) = s.sessions.values_mut().find(|sess| sess.token_fp == fp) {
861 sess.last_seen = now;
862 sess.attached = true;
863 }
864 })
865 .await?;
866 Ok(())
867 }
868
869 // ═══════════════════════════════════════════════════════════════════════
870 // Task lifecycle
871 // ═══════════════════════════════════════════════════════════════════════
872
873 /// Create a new `TaskState` from `spec` and register its initial
874 /// prompt. When the calling token is a Worker (i.e. this is a
875 /// recursive spawn), the new task inherits `parent.spawn_depth + 1`
876 /// and is rejected with `SpawnDepthExceeded` once `max_spawn_depth` is
877 /// hit; an Operator-issued call starts at depth 0.
878 pub async fn start_task(
879 &self,
880 token: &CapToken,
881 spec: TaskSpec,
882 ) -> Result<StepId, EngineError> {
883 self.verify_token(token, Verb::StartTask).await?;
884 let task_id = StepId::new();
885 let initial_directive = spec.initial_directive.clone();
886 let task_id_clone = task_id.clone();
887 let fp = token.fingerprint();
888 let max_depth = self.inner.cfg.max_spawn_depth;
889 self.with_state("start_task", move |s| {
890 // Recursive swarm depth gate (recursion guard):
891 // Worker tokens carry CapTokenRecord.parent_task_id. Give the
892 // child parent's spawn_depth + 1; if it exceeds `max`, raise an
893 // error. Operator tokens (parent_task_id=None) start at depth 0.
894 let parent_depth_opt = s
895 .tokens
896 .get(&fp)
897 .and_then(|rec| rec.task_id.as_ref())
898 .and_then(|tid| s.tasks.get(tid))
899 .map(|t| t.spawn_depth);
900 let depth = match parent_depth_opt {
901 Some(d) => {
902 if d + 1 >= max_depth {
903 return Err(EngineError::SpawnDepthExceeded {
904 current: d + 1,
905 max: max_depth,
906 });
907 }
908 d + 1
909 }
910 None => 0,
911 };
912
913 let mut task = TaskState::new(task_id_clone.clone(), spec);
914 task.spawn_depth = depth;
915 s.tasks.insert(task_id_clone.clone(), task);
916 s.prompts
917 .insert((task_id_clone.clone(), 1), initial_directive);
918 // Link to the owner session (only Operator tokens match; Worker tokens have no session).
919 if let Some(sess) = s.sessions.values_mut().find(|sess| sess.token_fp == fp) {
920 sess.owned_task_ids.push(task_id_clone.clone());
921 }
922 s.push_event(Event::TaskCreated {
923 task_id: task_id_clone.clone(),
924 });
925 Ok::<(), EngineError>(())
926 })
927 .await??;
928 let _ = self.inner.event_tx.send(Event::TaskCreated {
929 task_id: task_id.clone(),
930 });
931 Ok(task_id)
932 }
933
934 /// Fetch a snapshot of `TaskState` for `task_id`, subject to the
935 /// task-ownership gate (see `verify_token_for_task`).
936 pub async fn read_task_state(
937 &self,
938 token: &CapToken,
939 task_id: &StepId,
940 ) -> Result<TaskState, EngineError> {
941 self.verify_token_for_task(token, Verb::ReadTaskState, task_id)
942 .await?;
943 let task_id = task_id.clone();
944 self.with_state("read_task_state", move |s| {
945 s.tasks
946 .get(&task_id)
947 .cloned()
948 .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))
949 })
950 .await?
951 }
952
953 /// Mark `task_id` as `Cancelled` and wake any caller blocked in
954 /// `poll_task` for it.
955 pub async fn cancel_task(&self, token: &CapToken, task_id: &StepId) -> Result<(), EngineError> {
956 self.verify_token_for_task(token, Verb::CancelTask, task_id)
957 .await?;
958 let tid = task_id.clone();
959 self.with_state("cancel_task", move |s| {
960 let task = s
961 .tasks
962 .get_mut(&tid)
963 .ok_or_else(|| EngineError::TaskNotFound(tid.to_string()))?;
964 task.status = TaskStatus::Cancelled;
965 task.updated_at = now_unix();
966 s.push_event(Event::TaskCancelled {
967 task_id: tid.clone(),
968 });
969 Ok::<(), EngineError>(())
970 })
971 .await??;
972 self.wake_task(task_id).await?;
973 Ok(())
974 }
975
976 /// Dispatch a single attempt through the given `spawner`.
977 ///
978 /// The lock is only held for snapshot capture; the actual spawn and
979 /// completion await happen outside the lock (R3 discipline).
980 ///
981 /// Sits on the Domain side of the Data / Domain split. The dispatch
982 /// path itself does not touch big response bodies — those flow through
983 /// the Data plane (`output_store` module + sink / input_inject
984 /// `SpawnerLayer`s) around this method.
985 ///
986 /// The caller does the compile plus `service::linker::link` and
987 /// carries the same stack through each dispatch. Because the spawner
988 /// is passed per-request rather than looked up from engine-global
989 /// state, parallel requests against a single `Engine` instance
990 /// (different Blueprints, different spawners) do not race.
991 ///
992 /// `run_id`, when `Some` (issue #13 run_id propagation —
993 /// `EngineDispatcher` threads it in from its `RunContext`), is
994 /// inserted into `Ctx.meta.runtime["run_id"]` (a plain JSON string)
995 /// alongside `worker_handle`, so `Operator::execute` implementations
996 /// (e.g. `WSOperatorSession`) can read it back and surface it to the
997 /// worker (Spawn directive / prompt). `None` (every pre-existing
998 /// caller / test) omits the key entirely — unchanged behavior.
999 pub async fn dispatch_attempt_with(
1000 &self,
1001 token: &CapToken,
1002 task_id: &StepId,
1003 spawner: &Arc<dyn SpawnerAdapter>,
1004 run_id: Option<&RunId>,
1005 ) -> Result<DispatchOutcome, EngineError> {
1006 self.verify_token(token, Verb::DispatchAttempt).await?;
1007 let task_id = task_id.clone();
1008
1009 // 1) Under the lock: increment the attempt number, mark Running, snapshot the
1010 // prompt, and pull `operator_info` from the session so we can inject it into Ctx.
1011 let fp = token.fingerprint();
1012 let tid_for_prep = task_id.clone();
1013 let (attempt, agent, session_snapshot, step_ctx) = self
1014 .with_state("dispatch.prep", move |s| {
1015 let task = s
1016 .tasks
1017 .get_mut(&tid_for_prep)
1018 .ok_or_else(|| EngineError::TaskNotFound(tid_for_prep.to_string()))?;
1019 task.attempt += 1;
1020 task.status = TaskStatus::Running;
1021 task.updated_at = now_unix();
1022 // The spawner pulls the prompt via engine.fetch_prompt. In prep,
1023 // if the prompts table has no entry for this attempt yet,
1024 // fall back and insert `initial_directive` so the subsequent
1025 // fetch_prompt succeeds.
1026 let attempt = task.attempt;
1027 let initial = task.spec.initial_directive.clone();
1028 s.prompts
1029 .entry((tid_for_prep.clone(), attempt))
1030 .or_insert(initial);
1031 let task = s
1032 .tasks
1033 .get(&tid_for_prep)
1034 .ok_or_else(|| EngineError::TaskNotFound(tid_for_prep.to_string()))?;
1035 let agent = task.spec.agent.clone();
1036 // GH #21 Phase 2: re-read `TaskSpec.step_ctx` on EVERY
1037 // attempt (not cached once at start_task) so retries and
1038 // Run-rekicks all carry the Step tier through to Ctx —
1039 // see TaskSpec.step_ctx's doc.
1040 let step_ctx = task.spec.step_ctx.clone();
1041 // Session snapshot (looked up by token nonce). When no session
1042 // exists (worker token invoked directly / test injection), fall
1043 // back to None → default OperatorInfo.
1044 let sess_clone = s
1045 .sessions
1046 .values()
1047 .find(|sess| sess.token_fp == fp)
1048 .cloned();
1049 Ok::<_, EngineError>((attempt, agent, sess_clone, step_ctx))
1050 })
1051 .await??;
1052 // BridgeRegistry lookup + per-agent OperatorKind cascade.
1053 let operator_info = match session_snapshot {
1054 Some(sess) => self.resolve_operator_info(&sess, &agent).await,
1055 None => OperatorInfo::default(),
1056 };
1057
1058 // 2) Outside the lock: worker token mint + spawn.
1059 //
1060 // Session-style mint (max_uses=None). Within one attempt the worker is
1061 // expected to hit `verify_token + fetch_prompt + fetch_data + post_result`
1062 // multiple times in order, so `one_time` would exhaust the token on the
1063 // very first verb. Capability is guarded by (a) the role × verb gate and
1064 // (b) the short TTL (1800s).
1065 let worker_token = self.inner.signer.session(
1066 format!("worker-of-{task_id}"),
1067 Role::Worker,
1068 vec!["*".into()],
1069 Duration::from_secs(1800),
1070 );
1071 let worker_fp = worker_token.fingerprint();
1072 let task_id_for_worker = task_id.clone();
1073 let worker_token_for_store = worker_token.clone();
1074 self.with_state("dispatch.mint_worker", move |s| {
1075 s.tokens.insert(
1076 worker_fp,
1077 CapTokenRecord::from_worker_token(worker_token_for_store, task_id_for_worker),
1078 );
1079 })
1080 .await?;
1081
1082 // Mint a short handle (`wh-XXXXXXXX`) and register it in worker_handles.
1083 // Used by the simplified Bearer path for SubAgents (short-handle form
1084 // avoids base64 copy-paste incidents).
1085 let worker_handle = self.mint_worker_handle(worker_token.fingerprint()).await?;
1086
1087 let mut ctx = Ctx::new(task_id.clone(), attempt, agent.clone());
1088 ctx.operator = operator_info; // activates MainAIMiddleware / Senior bridge
1089 ctx.meta
1090 .runtime
1091 .insert("worker_handle".to_string(), Value::String(worker_handle));
1092 if let Some(rid) = run_id {
1093 ctx.meta
1094 .runtime
1095 .insert(RUN_ID_KEY.to_string(), Value::String(rid.to_string()));
1096 }
1097 // GH #21 Phase 2: the Step tier's resolved context bundle (from
1098 // `TaskSpec.step_ctx`, re-read every attempt above) — consumed by
1099 // `AgentContextMiddleware`, which unpacks its keys ahead of the
1100 // Agent / BP-global tiers.
1101 if let Some(step_ctx) = step_ctx {
1102 ctx.meta.runtime.insert(STEP_CTX_KEY.to_string(), step_ctx);
1103 }
1104
1105 let worker = spawner
1106 .spawn(self, &ctx, task_id.clone(), attempt, worker_token)
1107 .await
1108 .map_err(|e| EngineError::DispatchFailed(e.to_string()))?;
1109
1110 // 3) Outside the lock: await worker.join() (signal-only). WorkerError is
1111 // stringified. The value is fetched via output_tail (sink path).
1112 let signal_result: Result<(), String> = worker.join().await.map_err(|e| e.to_string());
1113
1114 // Pull the last Final from output_tail and use it as the value.
1115 let value_ok: Result<(Value, bool), String> = match signal_result {
1116 Ok(()) => {
1117 let tail = self.output_tail(&task_id, attempt).await;
1118 let last_final = tail.iter().rev().find_map(|ev| match ev {
1119 crate::worker::output::OutputEvent::Final { content, ok } => {
1120 Some((content.clone(), *ok))
1121 }
1122 _ => None,
1123 });
1124 match last_final {
1125 Some((crate::worker::output::ContentRef::Inline { value }, ok)) => {
1126 Ok((value, ok))
1127 }
1128 Some((
1129 crate::worker::output::ContentRef::FileRef {
1130 path,
1131 mime,
1132 size_hint,
1133 },
1134 ok,
1135 )) => Ok((
1136 serde_json::json!({
1137 "file_ref": path.to_string_lossy(),
1138 "mime": mime,
1139 "size_hint": size_hint,
1140 }),
1141 ok,
1142 )),
1143 None => Err("no Final in output_tail".to_string()),
1144 }
1145 }
1146 Err(msg) => Err(msg),
1147 };
1148
1149 // 4) Under the lock: apply (split the borrow scope so push_event and task mut can co-exist).
1150 let outcome = self
1151 .with_state("dispatch.apply", |s| {
1152 if !s.tasks.contains_key(&task_id) {
1153 return Err(EngineError::TaskNotFound(task_id.to_string()));
1154 }
1155 match value_ok {
1156 Ok((value, ok)) => {
1157 let pass = ok;
1158 {
1159 let task = s.tasks.get_mut(&task_id).unwrap();
1160 task.last_result = Some(value.clone());
1161 task.updated_at = now_unix();
1162 task.status = if pass {
1163 TaskStatus::Pass
1164 } else {
1165 TaskStatus::Blocked
1166 };
1167 }
1168 s.push_event(Event::TaskAttemptCompleted {
1169 task_id: task_id.clone(),
1170 attempt,
1171 result: value.clone(),
1172 });
1173 if pass {
1174 s.push_event(Event::TaskPass {
1175 task_id: task_id.clone(),
1176 result: value.clone(),
1177 });
1178 Ok::<_, EngineError>(DispatchOutcome::Pass(value))
1179 } else {
1180 s.push_event(Event::TaskBlocked {
1181 task_id: task_id.clone(),
1182 result: value.clone(),
1183 });
1184 Ok(DispatchOutcome::Blocked(value))
1185 }
1186 }
1187 Err(msg) => {
1188 let task = s.tasks.get_mut(&task_id).unwrap();
1189 task.status = TaskStatus::Blocked;
1190 task.updated_at = now_unix();
1191 Err(EngineError::DispatchFailed(msg))
1192 }
1193 }
1194 })
1195 .await??;
1196
1197 // event broadcast (outside the lock — push_event feeds the in-memory tail; broadcast is a separate path).
1198 let _ = self.inner.event_tx.send(Event::TaskAttemptCompleted {
1199 task_id: task_id.clone(),
1200 attempt,
1201 result: match &outcome {
1202 DispatchOutcome::Pass(v) | DispatchOutcome::Blocked(v) => v.clone(),
1203 _ => Value::Null,
1204 },
1205 });
1206
1207 // Wake any callers waiting in poll_task.
1208 self.wake_task(&task_id).await?;
1209
1210 Ok(outcome)
1211 }
1212
1213 // ═══════════════════════════════════════════════════════════════════════
1214 // Worker-side API (= prompt / data fetch + result post)
1215 // ═══════════════════════════════════════════════════════════════════════
1216
1217 /// Fetch the directive/prompt `Value` for `task_id`'s current attempt.
1218 /// Falls back to `initial_directive` when no prompt has been recorded
1219 /// yet for that attempt. Returns the `Value` end-to-end (issue #18);
1220 /// the render down to `String` happens only at the two consumer
1221 /// boundaries — the Worker HTTP path (`fetch_worker_payload*` →
1222 /// `WorkerPayload.prompt: String`) and the WS Spawn frame text
1223 /// render (`operator_ws::session`).
1224 pub async fn fetch_prompt(
1225 &self,
1226 token: &CapToken,
1227 task_id: &StepId,
1228 ) -> Result<Value, EngineError> {
1229 self.verify_token_for_task(token, Verb::FetchPrompt, task_id)
1230 .await?;
1231 let task_id = task_id.clone();
1232 self.with_state("fetch_prompt", move |s| {
1233 let task = s
1234 .tasks
1235 .get(&task_id)
1236 .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))?;
1237 s.prompts
1238 .get(&(task_id.clone(), task.attempt.max(1)))
1239 .cloned()
1240 .ok_or_else(|| {
1241 EngineError::ResourceNotFound(format!(
1242 "prompt({}, attempt={})",
1243 task_id, task.attempt
1244 ))
1245 })
1246 })
1247 .await?
1248 }
1249
1250 /// Combined fetch for `HTTP /v1/worker/prompt`: returns `prompt` +
1251 /// (optional) `system` + `agent` + `attempt` in a single round trip.
1252 /// The verb gate reuses `FetchPrompt` — same semantics as "the worker
1253 /// pulls its task input".
1254 ///
1255 /// `system` is the value written by `OperatorSpawner::spawn` through
1256 /// `bake_worker_system_prompt` when it ran; otherwise `None` (no
1257 /// profile present, or the bake never happened).
1258 pub async fn fetch_worker_payload(
1259 &self,
1260 token: &CapToken,
1261 task_id: &StepId,
1262 ) -> Result<crate::types::WorkerPayload, EngineError> {
1263 self.verify_token_for_task(token, Verb::FetchPrompt, task_id)
1264 .await?;
1265 let task_id_clone = task_id.clone();
1266 self.with_state("fetch_worker_payload", move |s| {
1267 let task = s
1268 .tasks
1269 .get(&task_id_clone)
1270 .ok_or_else(|| EngineError::TaskNotFound(task_id_clone.to_string()))?;
1271 let attempt = task.attempt.max(1);
1272 let prompt = s
1273 .prompts
1274 .get(&(task_id_clone.clone(), attempt))
1275 .cloned()
1276 .ok_or_else(|| {
1277 EngineError::ResourceNotFound(format!(
1278 "prompt({}, attempt={})",
1279 task_id_clone, attempt
1280 ))
1281 })?;
1282 let system = s
1283 .systems
1284 .get(&(task_id_clone.clone(), attempt))
1285 .cloned()
1286 .unwrap_or(None);
1287 let agent = task.spec.agent.clone();
1288 let context = s
1289 .agent_ctx
1290 .get(&(task_id_clone.clone(), attempt))
1291 .map(|e| e.view.clone());
1292 Ok::<_, EngineError>(crate::types::WorkerPayload {
1293 task_id: task_id_clone.clone(),
1294 attempt,
1295 agent,
1296 prompt: render_directive_to_string(&prompt),
1297 system,
1298 context,
1299 })
1300 })
1301 .await?
1302 }
1303
1304 /// Fetch a worker payload via a short handle. Skips token verification
1305 /// and returns `prompt` + `system` + `agent` + `attempt` in a thin
1306 /// path. The caller is expected to have already resolved `task_id`
1307 /// via `task_id_from_handle` — the handle's presence in
1308 /// `worker_handles` means it was minted server-side and is therefore
1309 /// trusted.
1310 pub async fn fetch_worker_payload_trusted(
1311 &self,
1312 task_id: &StepId,
1313 ) -> Result<crate::types::WorkerPayload, EngineError> {
1314 let task_id_clone = task_id.clone();
1315 self.with_state("fetch_worker_payload_trusted", move |s| {
1316 let task = s
1317 .tasks
1318 .get(&task_id_clone)
1319 .ok_or_else(|| EngineError::TaskNotFound(task_id_clone.to_string()))?;
1320 let attempt = task.attempt.max(1);
1321 let prompt = s
1322 .prompts
1323 .get(&(task_id_clone.clone(), attempt))
1324 .cloned()
1325 .ok_or_else(|| {
1326 EngineError::ResourceNotFound(format!(
1327 "prompt({}, attempt={})",
1328 task_id_clone, attempt
1329 ))
1330 })?;
1331 let system = s
1332 .systems
1333 .get(&(task_id_clone.clone(), attempt))
1334 .cloned()
1335 .unwrap_or(None);
1336 let agent = task.spec.agent.clone();
1337 let context = s
1338 .agent_ctx
1339 .get(&(task_id_clone.clone(), attempt))
1340 .map(|e| e.view.clone());
1341 Ok::<_, EngineError>(crate::types::WorkerPayload {
1342 task_id: task_id_clone.clone(),
1343 attempt,
1344 agent,
1345 prompt: render_directive_to_string(&prompt),
1346 system,
1347 context,
1348 })
1349 })
1350 .await?
1351 }
1352
1353 /// Returns the effective [`mlua_swarm_schema::ContextPolicy`]
1354 /// `AgentContextMiddleware` resolved and snapshotted for `(task_id,
1355 /// attempt)` at spawn time (the same policy already applied to that
1356 /// key's `EngineState.agent_ctx` entry's `.view`, GH #23 fold).
1357 /// Pass-all (`ContextPolicy::default()`) when no entry exists — either
1358 /// a pre-ST5 spawn, or a spawner stack that never layered
1359 /// `AgentContextMiddleware` (fail-open, mirroring [`Self::output_tail`]'s
1360 /// "no entry = empty default" convention).
1361 ///
1362 /// `crates/mlua-swarm-server/src/worker.rs`'s `GET /v1/worker/prompt`
1363 /// handler reads this back to filter `WorkerPayload.context.steps` via
1364 /// `ContextPolicy::allows_step`, without re-deriving the policy from
1365 /// the Blueprint at fetch time (`projection-adapter` ST5).
1366 pub async fn context_policy_for(
1367 &self,
1368 task_id: &StepId,
1369 attempt: u32,
1370 ) -> mlua_swarm_schema::ContextPolicy {
1371 let key = (task_id.clone(), attempt);
1372 self.with_state("context_policy_for", move |s| {
1373 s.agent_ctx
1374 .get(&key)
1375 .map(|e| e.policy.clone())
1376 .unwrap_or_default()
1377 })
1378 .await
1379 .unwrap_or_default()
1380 }
1381
1382 /// GH #23: returns the Blueprint-wide
1383 /// [`crate::core::step_naming::StepNaming`] table snapshotted for
1384 /// `task_id` (the same `Arc` `crate::blueprint::EngineDispatcher::dispatch`
1385 /// stashed into `EngineState.step_namings` at dispatch time —
1386 /// `Self::start_task`'s `StepId`, not the `TaskId` work item). `None`
1387 /// when no entry exists — either the dispatcher was never given a
1388 /// `StepNaming` (`EngineDispatcher::with_step_naming` not called) or
1389 /// the lock could not be acquired; callers are expected to fall back
1390 /// to the pre-GH-#23 runtime union rule in that case (subtask-2/3
1391 /// consumers).
1392 pub async fn step_naming_for(
1393 &self,
1394 task_id: &StepId,
1395 ) -> Option<Arc<crate::core::step_naming::StepNaming>> {
1396 let key = task_id.clone();
1397 self.with_state("step_naming_for", move |s| {
1398 s.step_namings.get(&key).cloned()
1399 })
1400 .await
1401 .ok()
1402 .flatten()
1403 }
1404
1405 /// GH #27 (follow-up to #23): returns the Blueprint-wide
1406 /// [`crate::core::projection_placement::ProjectionPlacement`] resolver
1407 /// snapshotted for `task_id` (the same `Arc`
1408 /// `crate::blueprint::EngineDispatcher::dispatch` stashed into
1409 /// `EngineState.projection_placements` at dispatch time — mirroring
1410 /// [`Self::step_naming_for`]'s contract exactly). `None` when no entry
1411 /// exists — either the dispatcher was never given a
1412 /// `ProjectionPlacement` (`EngineDispatcher::with_projection_placement`
1413 /// not called) or the lock could not be acquired; callers are expected
1414 /// to fall back to `ProjectionPlacement::default()` (byte-compat with
1415 /// the pre-#27 hardcoded layout) in that case.
1416 pub async fn projection_placement_for(
1417 &self,
1418 task_id: &StepId,
1419 ) -> Option<Arc<crate::core::projection_placement::ProjectionPlacement>> {
1420 let key = task_id.clone();
1421 self.with_state("projection_placement_for", move |s| {
1422 s.projection_placements.get(&key).cloned()
1423 })
1424 .await
1425 .ok()
1426 .flatten()
1427 }
1428
1429 /// Returns the [`crate::core::agent_context::AgentContextView`]
1430 /// snapshotted for `(task_id, attempt)`, if `AgentContextMiddleware`
1431 /// stashed one — the same lookup [`Self::fetch_worker_payload`] /
1432 /// [`Self::fetch_worker_payload_trusted`] perform inline, exposed
1433 /// standalone for callers that only need the view (not a full
1434 /// `WorkerPayload`) — e.g. the HTTP debug-plane `GET
1435 /// /v1/tasks/:id/runs/:run/steps*` handlers resolving a
1436 /// materialized-file root for a step *other than* the one currently
1437 /// fetching its own prompt (`projection-adapter` ST5).
1438 pub async fn agent_context_for(
1439 &self,
1440 task_id: &StepId,
1441 attempt: u32,
1442 ) -> Option<crate::core::agent_context::AgentContextView> {
1443 let key = (task_id.clone(), attempt);
1444 self.with_state("agent_context_for", move |s| {
1445 s.agent_ctx.get(&key).map(|e| e.view.clone())
1446 })
1447 .await
1448 .ok()
1449 .flatten()
1450 }
1451
1452 /// Read the current attempt number for a task (server-side lookup, no
1453 /// token verification). Used on `HTTP /v1/worker/result` when the
1454 /// worker omits `attempt` and the server has to fill it in.
1455 pub async fn task_attempt(&self, task_id: &StepId) -> Result<u32, EngineError> {
1456 let task_id = task_id.clone();
1457 self.with_state("task_attempt", move |s| {
1458 s.tasks
1459 .get(&task_id)
1460 .map(|t| t.attempt)
1461 .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))
1462 })
1463 .await?
1464 }
1465
1466 /// Server-side admin API that lets `OperatorSpawner::spawn` bake the
1467 /// rendered `system_prompt` into engine state. There is no verb gate
1468 /// — the only expected caller is inside the spawner. SubAgents fetch
1469 /// this alongside the prompt on the `/v1/worker/prompt` path.
1470 pub async fn bake_worker_system_prompt(
1471 &self,
1472 task_id: &StepId,
1473 attempt: u32,
1474 system: Option<String>,
1475 ) -> Result<(), EngineError> {
1476 let task_id = task_id.clone();
1477 self.with_state("bake_worker_system_prompt", move |s| {
1478 s.systems.insert((task_id, attempt), system);
1479 })
1480 .await?;
1481 Ok(())
1482 }
1483
1484 /// Fetch an arbitrary named resource previously stored via
1485 /// `set_resource`. Not task-scoped — any valid token with the
1486 /// `FetchData` verb may read any key.
1487 pub async fn fetch_data(&self, token: &CapToken, key: &str) -> Result<Value, EngineError> {
1488 self.verify_token(token, Verb::FetchData).await?;
1489 let key = key.to_string();
1490 self.with_state("fetch_data", move |s| {
1491 s.resources
1492 .get(&key)
1493 .cloned()
1494 .ok_or(EngineError::ResourceNotFound(key))
1495 })
1496 .await?
1497 }
1498
1499 // ───────────────────────────────────────────────────────────────────────
1500 // Output path.
1501 // ───────────────────────────────────────────────────────────────────────
1502
1503 /// Send one output event from inside a `SpawnerAdapter` or worker.
1504 /// Structuring is assumed to be complete by the time we cross the
1505 /// `SpawnerAdapter` boundary; this API just appends to the
1506 /// `OutputStore`, pushes to the `EventLog`, and (for `Final`) emits
1507 /// the `TaskAttemptCompleted` event.
1508 ///
1509 /// This is Domain-side plumbing: it feeds the engine's verdict flow,
1510 /// not the Data-plane store in the `output_store` module. It also
1511 /// does not wake the dispatch path — that is done through the
1512 /// spawner's completion oneshot when the worker terminates.
1513 ///
1514 /// # Submit-time projection sink (subtask-4 / ST2 rework)
1515 ///
1516 /// A `Final` event additionally fans out to the submit-time projection
1517 /// sink ([`Self::materialize_final_submission`]): (a) when
1518 /// [`Self::set_output_store`] has wired a Data-plane
1519 /// [`crate::store::output::OutputStore`], the event is dual-written
1520 /// there (`producer_agent` = `TaskState.spec.agent`, resolved to its
1521 /// GH #23 canonical projection name — see below), and (b) when this
1522 /// task's spawn ran through `AgentContextMiddleware` (so
1523 /// `EngineState.agent_ctx` has a `.view.work_dir` / `.view.project_root`
1524 /// for it), the value is additionally materialized to the
1525 /// [`crate::core::projection_placement::ProjectionPlacement`]
1526 /// resolver's target (byte-compat default layout
1527 /// `<root>/workspace/tasks/<task_id>/ctx/<canonical_agent>.md`) — see
1528 /// `crate::core::projection`'s module doc.
1529 ///
1530 /// **GH #23 subtask-2 (canonical sink):** both writes above key off the
1531 /// canonical name — `Engine::step_naming_for(task_id)`'s
1532 /// `StepNaming::canonical_of_producer(producer_agent)` when a table was
1533 /// snapshotted for this task (`EngineDispatcher::with_step_naming`),
1534 /// else `producer_agent` unchanged (fail-open, byte-identical to
1535 /// pre-GH-#23 behavior — see [`crate::core::step_naming`]'s module
1536 /// doc).
1537 ///
1538 /// **Invariants** (Subtask 4): (1) this sink is fail-open — an
1539 /// unresolved root, an unconfigured `OutputStore`, or either one
1540 /// erroring, only logs a `tracing::warn!` and never turns this
1541 /// `Ok(())` into an `Err`; (2) the wired `OutputStore` stays the single
1542 /// source of truth for cross-step queries — the materialized file is a
1543 /// projection of it, not a second store; (3) core does not depend on
1544 /// `mlua-swarm-server` — everything this sink touches
1545 /// (`crate::store::output` / `crate::core::projection`) already lives
1546 /// in this crate.
1547 pub async fn submit_output(
1548 &self,
1549 token: &crate::types::CapToken,
1550 task_id: &StepId,
1551 attempt: u32,
1552 event: crate::worker::output::OutputEvent,
1553 ) -> Result<(), EngineError> {
1554 self.verify_token_for_task(token, crate::types::Verb::EmitOutput, task_id)
1555 .await?;
1556 let task_id_for_apply = task_id.clone();
1557 let event_clone = event.clone();
1558 self.with_state("submit_output", move |s| {
1559 s.output_store
1560 .entry((task_id_for_apply.clone(), attempt))
1561 .or_default()
1562 .push(event_clone.clone());
1563 s.push_event(crate::core::state::Event::WorkerOutput {
1564 task_id: task_id_for_apply,
1565 attempt,
1566 event: event_clone,
1567 });
1568 })
1569 .await?;
1570 if let crate::worker::output::OutputEvent::Final { content, ok } = &event {
1571 self.materialize_final_submission(task_id, attempt, content, *ok)
1572 .await;
1573 }
1574 Ok(())
1575 }
1576
1577 /// Submit-time projection sink (subtask-4 / ST2 rework) shared by
1578 /// [`Self::submit_output`] and [`Self::submit_worker_result_trusted`].
1579 /// Best-effort / fail-open throughout (see `submit_output`'s doc
1580 /// Invariants): every failure path only `tracing::warn!`s and returns.
1581 ///
1582 /// Reads `(producer_agent, view)` via one read-only [`Self::with_state`]
1583 /// call — `producer_agent` off `TaskState.spec.agent`, `view` (the
1584 /// full [`crate::core::agent_context::AgentContextView`]) off
1585 /// `EngineState.agent_ctx[(task_id, attempt)]`, the same snapshot
1586 /// `crate::middleware::agent_context::AgentContextMiddleware` writes at
1587 /// spawn time — then does its actual (dual-write / file-write) work
1588 /// *outside* that lock, so a slow disk write or Data-plane store call
1589 /// never holds up unrelated `Engine::with_state` callers. `root` itself
1590 /// is resolved from `view` AFTER the lock via
1591 /// [`crate::core::projection_placement::ProjectionPlacement::resolve_root`]
1592 /// (GH #27, follow-up to #23) — the SAME resolver
1593 /// [`Self::step_naming_for`]'s sibling accessor
1594 /// [`Self::projection_placement_for`] snapshotted at dispatch time, so
1595 /// this sink's root-preference / fallback order is identical to the
1596 /// server read-back and the spawn-time pointer.
1597 async fn materialize_final_submission(
1598 &self,
1599 task_id: &StepId,
1600 attempt: u32,
1601 content: &crate::worker::output::ContentRef,
1602 ok: bool,
1603 ) {
1604 let task_id_for_lookup = task_id.clone();
1605 let lookup = self
1606 .with_state("materialize_final_submission.lookup", move |s| {
1607 let producer_agent = s
1608 .tasks
1609 .get(&task_id_for_lookup)
1610 .map(|t| t.spec.agent.clone());
1611 let view = s
1612 .agent_ctx
1613 .get(&(task_id_for_lookup.clone(), attempt))
1614 .map(|e| e.view.clone());
1615 (producer_agent, view)
1616 })
1617 .await;
1618 let (producer_agent, view) = match lookup {
1619 Ok(pair) => pair,
1620 Err(err) => {
1621 tracing::warn!(
1622 %task_id,
1623 error = %err,
1624 "submit-time projection sink: state lookup failed; skipping (fail-open)"
1625 );
1626 return;
1627 }
1628 };
1629 let Some(producer_agent) = producer_agent else {
1630 // Defensive only: `task_id` is always a just-looked-up task at
1631 // every real call site. No task, no addressable producer name
1632 // — nothing to project.
1633 return;
1634 };
1635 let placement = self
1636 .projection_placement_for(task_id)
1637 .await
1638 .unwrap_or_default();
1639 let root = view.and_then(|v| placement.resolve_root(&v));
1640
1641 // GH #23 subtask-2: resolve `producer_agent` to its canonical
1642 // projection name via the Blueprint-wide `StepNaming` table
1643 // snapshotted at dispatch time (`Engine::step_naming_for`). Both
1644 // write paths below ((a) data-plane, (b) file stem) use the
1645 // *canonical* name — `StepNaming::canonical_of_producer` returns
1646 // `producer_agent` unchanged for undeclared steps (byte-identical
1647 // to pre-GH-#23 behavior), and `None` (no table for this
1648 // `task_id`, e.g. a spawn that never went through
1649 // `EngineDispatcher::with_step_naming`) is a defensive fail-open
1650 // to the raw `producer_agent`, same discipline as the rest of this
1651 // sink.
1652 let canonical_agent = self
1653 .step_naming_for(task_id)
1654 .await
1655 .and_then(|naming| {
1656 naming
1657 .canonical_of_producer(&producer_agent)
1658 .map(str::to_string)
1659 })
1660 .unwrap_or_else(|| producer_agent.clone());
1661
1662 // (a) Data-plane dual-write, when an OutputStore backend is wired.
1663 if let Some(store) = self.output_store_backend() {
1664 if let Err(err) = store
1665 .append(
1666 task_id.as_str(),
1667 attempt,
1668 &canonical_agent,
1669 crate::worker::output::OutputEvent::Final {
1670 content: content.clone(),
1671 ok,
1672 },
1673 Vec::new(),
1674 )
1675 .await
1676 {
1677 tracing::warn!(
1678 %task_id,
1679 agent = %producer_agent,
1680 canonical = %canonical_agent,
1681 error = %err,
1682 "submit-time projection sink: OutputStore dual-write failed (fail-open)"
1683 );
1684 }
1685 }
1686
1687 // (b) File materialize, when a root resolved.
1688 let Some(root) = root else {
1689 tracing::warn!(
1690 %task_id,
1691 agent = %producer_agent,
1692 canonical = %canonical_agent,
1693 "submit-time projection sink: no work_dir/project_root resolved; skipping file materialize (fail-open)"
1694 );
1695 return;
1696 };
1697 let value = match content {
1698 crate::worker::output::ContentRef::Inline { value } => value.clone(),
1699 crate::worker::output::ContentRef::FileRef {
1700 path,
1701 mime,
1702 size_hint,
1703 } => serde_json::json!({
1704 "file_ref": path.to_string_lossy(),
1705 "mime": mime,
1706 "size_hint": size_hint,
1707 }),
1708 };
1709 let key = crate::core::projection::ProjectionKey {
1710 task_id: task_id.to_string(),
1711 run_id: None,
1712 step: Some(canonical_agent.clone()),
1713 path: None,
1714 };
1715 let adapter = crate::core::projection::FileProjectionAdapter::with_placement(
1716 root,
1717 (*placement).clone(),
1718 );
1719 if let Err(err) = adapter.materialize_submission(&key, &value, attempt, ok) {
1720 tracing::warn!(
1721 %task_id,
1722 agent = %producer_agent,
1723 canonical = %canonical_agent,
1724 error = %err,
1725 "submit-time projection sink: file materialize failed (fail-open)"
1726 );
1727 }
1728 }
1729
1730 /// Snapshot the entire output tail for a given `(task_id, attempt)`.
1731 /// Used by the dispatch path when pulling `Final`, and by observers
1732 /// reading the trace.
1733 pub async fn output_tail(
1734 &self,
1735 task_id: &StepId,
1736 attempt: u32,
1737 ) -> Vec<crate::worker::output::OutputEvent> {
1738 let key = (task_id.clone(), attempt);
1739 self.with_state("output_tail", move |s| {
1740 s.output_store.get(&key).cloned().unwrap_or_default()
1741 })
1742 .await
1743 .unwrap_or_default()
1744 }
1745
1746 /// Record an interim `last_result` for `task_id` without changing its
1747 /// `status`. Distinct from the terminal `Final` output event handled
1748 /// through `submit_output` / `dispatch_attempt_with`.
1749 pub async fn post_result(
1750 &self,
1751 token: &CapToken,
1752 task_id: &StepId,
1753 result: Value,
1754 ) -> Result<(), EngineError> {
1755 self.verify_token_for_task(token, Verb::PostResult, task_id)
1756 .await?;
1757 let task_id = task_id.clone();
1758 let result_clone = result.clone();
1759 self.with_state("post_result", move |s| {
1760 let task = s
1761 .tasks
1762 .get_mut(&task_id)
1763 .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))?;
1764 task.last_result = Some(result_clone);
1765 task.updated_at = now_unix();
1766 Ok::<(), EngineError>(())
1767 })
1768 .await??;
1769 Ok(())
1770 }
1771
1772 /// Store a named resource value, retrievable later via `fetch_data`.
1773 /// No token is required — this is a server-side/admin-style setter
1774 /// (mirrors `bake_worker_system_prompt`).
1775 pub async fn set_resource(
1776 &self,
1777 key: impl Into<String>,
1778 value: Value,
1779 ) -> Result<(), EngineError> {
1780 let key = key.into();
1781 self.with_state("set_resource", move |s| {
1782 s.resources.insert(key, value);
1783 })
1784 .await?;
1785 Ok(())
1786 }
1787
1788 // ═══════════════════════════════════════════════════════════════════════
1789 // Senior suspend / resume
1790 // ═══════════════════════════════════════════════════════════════════════
1791
1792 /// Ask a question of the Senior, mark the task `Suspended`, and
1793 /// return a `ResumeKey`. The suspended state persists until another
1794 /// task calls `resume(key, answer)`.
1795 ///
1796 /// Resume-side waiting is `Notify`-based, so a caller (typically
1797 /// MainAI) can detach, reattach from a different process, and still
1798 /// pull the answer out via `await_resume(key, timeout)` — the answer
1799 /// is stored inside `EngineState`.
1800 pub async fn query_senior(
1801 &self,
1802 token: &CapToken,
1803 task_id: &StepId,
1804 question: Value,
1805 ) -> Result<ResumeKey, EngineError> {
1806 self.verify_token(token, Verb::QuerySenior).await?;
1807 let task_id = task_id.clone();
1808 let key = ResumeKey::for_senior(&task_id);
1809 let task_notify = self
1810 .with_state("query_senior.notify_ensure", |s| {
1811 s.ensure_task_notify(&task_id)
1812 })
1813 .await?;
1814
1815 let key_clone = key.clone();
1816 let task_id_inner = task_id.clone();
1817 let question_clone = question.clone();
1818 self.with_state("query_senior.suspend", move |s| {
1819 let task = s
1820 .tasks
1821 .get_mut(&task_id_inner)
1822 .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))?;
1823 task.status = TaskStatus::Suspended;
1824 task.suspended_on = Some(key_clone.clone());
1825 task.updated_at = now_unix();
1826 s.pending_resumes
1827 .insert(key_clone.clone(), ResumePending::new());
1828 s.push_event(Event::SeniorQueried {
1829 task_id: task_id_inner.clone(),
1830 question: question_clone.clone(),
1831 });
1832 s.push_event(Event::TaskSuspended {
1833 task_id: task_id_inner.clone(),
1834 key: key_clone.clone(),
1835 });
1836 Ok::<(), EngineError>(())
1837 })
1838 .await??;
1839
1840 // Notify callers waiting for a task status change (Running → Suspended).
1841 task_notify.notify_waiters();
1842
1843 let _ = self
1844 .inner
1845 .event_tx
1846 .send(Event::SeniorQueried { task_id, question });
1847 Ok(key)
1848 }
1849
1850 /// Store the answer for a `ResumeKey` in `EngineState` and wake the
1851 /// waiting caller via `Notify`. Also flips the suspended task's
1852 /// status back to `Running` and fires the per-task notifier.
1853 pub async fn resume(&self, key: ResumeKey, answer: Value) -> Result<(), EngineError> {
1854 let answer_for_state = answer.clone();
1855 let answer_for_event = answer.clone();
1856 let key_clone = key.clone();
1857 let (notify, task_notify, task_id_opt) = self
1858 .with_state("resume.set", move |s| {
1859 let pending = s
1860 .pending_resumes
1861 .get_mut(&key_clone)
1862 .ok_or(EngineError::ResumeKeyNotFound)?;
1863 pending.answer = Some(answer_for_state);
1864 let notify = pending.notify.clone();
1865
1866 let task_id = s
1867 .tasks
1868 .iter()
1869 .find(|(_, t)| t.suspended_on.as_ref() == Some(&key_clone))
1870 .map(|(id, _)| id.clone());
1871
1872 let task_notify = task_id.as_ref().map(|tid| s.ensure_task_notify(tid));
1873
1874 if let Some(tid) = &task_id {
1875 if let Some(task) = s.tasks.get_mut(tid) {
1876 task.suspended_on = None;
1877 task.status = TaskStatus::Running;
1878 task.updated_at = now_unix();
1879 }
1880 s.push_event(Event::TaskResumed {
1881 task_id: tid.clone(),
1882 key: key_clone.clone(),
1883 });
1884 s.push_event(Event::SeniorAnswered {
1885 task_id: tid.clone(),
1886 answer: answer_for_event.clone(),
1887 });
1888 }
1889 Ok::<_, EngineError>((notify, task_notify, task_id))
1890 })
1891 .await??;
1892
1893 // Outside the lock: notify_waiters for both the ResumePending and task-status waits.
1894 notify.notify_waiters();
1895 if let Some(n) = task_notify {
1896 n.notify_waiters();
1897 }
1898
1899 if let Some(tid) = task_id_opt {
1900 let _ = self
1901 .inner
1902 .event_tx
1903 .send(Event::TaskResumed { task_id: tid, key });
1904 }
1905 Ok(())
1906 }
1907
1908 /// Wait for the resume answer. Even if the caller (an Operator)
1909 /// detached and reattached, the answer is available immediately here
1910 /// — if it was already stored, this returns without waiting on the
1911 /// notifier.
1912 ///
1913 /// `timeout = Duration::ZERO` performs an instant check without
1914 /// waiting.
1915 pub async fn await_resume(
1916 &self,
1917 key: ResumeKey,
1918 timeout: Duration,
1919 ) -> Result<Value, EngineError> {
1920 // (1) Under the lock: clone the notify handle and check for an existing answer.
1921 let key_clone = key.clone();
1922 let (notify, existing) = self
1923 .with_state("await_resume.snapshot", move |s| {
1924 let pending = s
1925 .pending_resumes
1926 .get(&key_clone)
1927 .ok_or(EngineError::ResumeKeyNotFound)?;
1928 Ok::<_, EngineError>((pending.notify.clone(), pending.answer.clone()))
1929 })
1930 .await??;
1931
1932 // (2) If an answer has already been stored, return immediately (detach / reattach pattern).
1933 if let Some(v) = existing {
1934 return Ok(v);
1935 }
1936
1937 // (3) Outside the lock: wait on the notify with a timeout.
1938 if timeout.is_zero() {
1939 return Err(EngineError::PollTimeout);
1940 }
1941 let waited = tokio::time::timeout(timeout, notify.notified()).await;
1942 if waited.is_err() {
1943 return Err(EngineError::PollTimeout);
1944 }
1945
1946 // (4) Under the lock: re-read the answer (should be present now that we were notified).
1947 let key_clone = key.clone();
1948 self.with_state("await_resume.read", move |s| {
1949 let pending = s
1950 .pending_resumes
1951 .get(&key_clone)
1952 .ok_or(EngineError::ResumeKeyNotFound)?;
1953 pending
1954 .answer
1955 .clone()
1956 .ok_or_else(|| EngineError::Internal("notified but answer missing".into()))
1957 })
1958 .await?
1959 }
1960
1961 // ═══════════════════════════════════════════════════════════════════════
1962 // poll_task — the "wait" path that waits for task-status changes (works for long-poll and regular wait).
1963 // ═══════════════════════════════════════════════════════════════════════
1964
1965 /// Wait until the task's status **transitions to terminal or
1966 /// `Suspended`**, then return the latest `TaskState`. Returns
1967 /// immediately if the task is already in a terminal state.
1968 /// Exceeding the timeout returns `EngineError::PollTimeout`.
1969 ///
1970 /// A `hold` of `Duration::from_secs(0)` returns a snapshot immediately
1971 /// (no wait). Larger holds — tens of minutes up to days — are fine;
1972 /// the wait state is kept in memory inside the engine and does not
1973 /// degrade.
1974 pub async fn poll_task(
1975 &self,
1976 token: &CapToken,
1977 task_id: &StepId,
1978 hold: Duration,
1979 ) -> Result<TaskState, EngineError> {
1980 self.verify_token_for_task(token, Verb::PollTask, task_id)
1981 .await?;
1982 let task_id_inner = task_id.clone();
1983
1984 // (1) Under the lock: take a snapshot and clone task_notify.
1985 let (state, notify) = self
1986 .with_state("poll_task.snapshot", move |s| {
1987 let task = s
1988 .tasks
1989 .get(&task_id_inner)
1990 .cloned()
1991 .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))?;
1992 let notify = s.ensure_task_notify(&task_id_inner);
1993 Ok::<_, EngineError>((task, notify))
1994 })
1995 .await??;
1996
1997 // (2) Immediate-return condition: already terminal / Suspended (nothing left to wait on).
1998 if matches!(
1999 state.status,
2000 TaskStatus::Pass | TaskStatus::Blocked | TaskStatus::Cancelled | TaskStatus::Suspended
2001 ) {
2002 return Ok(state);
2003 }
2004 if hold.is_zero() {
2005 return Ok(state);
2006 }
2007
2008 // (3) Outside the lock: wait on Notify with a timeout.
2009 let waited = tokio::time::timeout(hold, notify.notified()).await;
2010 if waited.is_err() {
2011 return Err(EngineError::PollTimeout);
2012 }
2013
2014 // (4) Under the lock: take a fresh snapshot.
2015 let task_id_inner = task_id.clone();
2016 self.with_state("poll_task.reread", move |s| {
2017 s.tasks
2018 .get(&task_id_inner)
2019 .cloned()
2020 .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))
2021 })
2022 .await?
2023 }
2024
2025 // ═══════════════════════════════════════════════════════════════════════
2026 // Background: heartbeat miss → detach loop
2027 // ═══════════════════════════════════════════════════════════════════════
2028
2029 /// Background loop that scans sessions every `heartbeat_interval` and
2030 /// flips `attached = false` on any session whose `last_seen` exceeds
2031 /// `heartbeat_miss_threshold * interval`.
2032 ///
2033 /// The tasks themselves are kept (assuming
2034 /// `keepalive_on_idle = true`), so another client can reattach with
2035 /// the same token and resume immediately. Dropping the returned
2036 /// `JoinHandle` does not stop the loop — the handle exists so callers
2037 /// who want to abort can hold onto it.
2038 pub fn start_detach_loop(&self) -> tokio::task::JoinHandle<()> {
2039 let engine = self.clone();
2040 let cfg = self.inner.cfg.long_hold.clone();
2041 let interval = cfg.heartbeat_interval;
2042 let miss_secs = cfg.heartbeat_interval.as_secs() * cfg.heartbeat_miss_threshold as u64;
2043
2044 tokio::spawn(async move {
2045 let mut ticker = tokio::time::interval(interval);
2046 ticker.tick().await; // first tick is immediate
2047 loop {
2048 ticker.tick().await;
2049 let now = now_unix();
2050 let detached = engine
2051 .with_state("detach_loop.scan", |s| {
2052 let mut detached = Vec::new();
2053 for (sid, sess) in s.sessions.iter_mut() {
2054 if !sess.attached {
2055 continue;
2056 }
2057 if now.saturating_sub(sess.last_seen) >= miss_secs {
2058 sess.attached = false;
2059 detached.push(sid.clone());
2060 }
2061 }
2062 for sid in &detached {
2063 s.push_event(Event::SessionDetached {
2064 session_id: sid.clone(),
2065 });
2066 }
2067 detached
2068 })
2069 .await
2070 .unwrap_or_default();
2071 for sid in detached {
2072 let _ = engine
2073 .inner
2074 .event_tx
2075 .send(Event::SessionDetached { session_id: sid });
2076 }
2077 }
2078 })
2079 }
2080
2081 /// Helper: wake a task whose status has changed. Called from the
2082 /// method body outside the lock.
2083 async fn wake_task(&self, task_id: &StepId) -> Result<(), EngineError> {
2084 let task_id = task_id.clone();
2085 let notify_opt = self
2086 .with_state("wake_task.get_notify", move |s| {
2087 s.task_notifies.get(&task_id).cloned()
2088 })
2089 .await?;
2090 if let Some(n) = notify_opt {
2091 n.notify_waiters();
2092 }
2093 Ok(())
2094 }
2095}
2096
2097// ─── UT: issue #14 — token store keyed by fingerprint, not nonce ────────────
2098#[cfg(test)]
2099mod token_fingerprint_store_tests {
2100 use super::*;
2101
2102 /// A token that was never attached fails verify with a `TokenNotFound`
2103 /// that carries the fingerprint — never the nonce. The error string can
2104 /// surface in HTTP error bodies, so this is the secret-hygiene contract.
2105 #[tokio::test]
2106 async fn verify_unknown_token_reports_fingerprint_not_nonce() {
2107 let engine = Engine::new(EngineCfg::default());
2108 // Signed by the engine's own signer (sig passes) but never inserted
2109 // into the store — verify must fail at step (4), the store lookup.
2110 let token = engine.signer().session(
2111 "ghost",
2112 Role::Operator,
2113 vec!["*".into()],
2114 Duration::from_secs(60),
2115 );
2116 let err = engine
2117 .verify_token(&token, Verb::ReadTaskState)
2118 .await
2119 .expect_err("token is not in the store");
2120 let msg = err.to_string();
2121 assert!(
2122 msg.contains(&token.fingerprint()),
2123 "error must carry the fingerprint: {msg}"
2124 );
2125 assert!(
2126 !msg.contains(&token.nonce),
2127 "error must not leak the nonce: {msg}"
2128 );
2129 }
2130
2131 /// attach → verify → heartbeat → detach all resolve the session /
2132 /// token record through fingerprint keys (mint/verify lifecycle
2133 /// regression guard for the issue #14 key migration).
2134 #[tokio::test]
2135 async fn attach_verify_heartbeat_detach_cycle_with_fp_keying() {
2136 let engine = Engine::new(EngineCfg::default());
2137 let token = engine
2138 .attach("op-1", Role::Operator, Duration::from_secs(60))
2139 .await
2140 .expect("attach");
2141 engine
2142 .verify_token(&token, Verb::ReadTaskState)
2143 .await
2144 .expect("verify consumes via fp key");
2145 engine
2146 .heartbeat(&token)
2147 .await
2148 .expect("heartbeat finds the session by fp");
2149 engine
2150 .detach(&token)
2151 .await
2152 .expect("detach finds the session by fp");
2153 }
2154}
2155
2156// ─── UT: `OperatorKind` "Runtime Global" tier — `Option` semantics ─────────
2157//
2158// Regression coverage for the "explicit Automate is indistinguishable from
2159// unspecified" defect: `OperatorSession.operator_kind` (and the
2160// `attach_with_ids` `kind` parameter it stores) is `Option<OperatorKind>`,
2161// so `Some(Automate)` is an explicit Runtime Global request that must
2162// outrank `bp_global`, while `None` must let `bp_global` decide. Exercises
2163// the real `resolve_operator_info` cascade path (not just
2164// `collapse_operator_kind` in isolation), attaching via `attach_with_ids`
2165// exactly as `TaskLaunchService::launch` does.
2166#[cfg(test)]
2167mod resolve_operator_info_runtime_global_tests {
2168 use super::*;
2169
2170 async fn attach_and_resolve(
2171 runtime_global: Option<OperatorKind>,
2172 bp_global: Option<OperatorKind>,
2173 ) -> OperatorInfo {
2174 let engine = Engine::new(EngineCfg::default());
2175 let token = engine
2176 .attach_with_ids(
2177 "ut-op",
2178 Role::Operator,
2179 Duration::from_secs(30),
2180 runtime_global,
2181 None,
2182 None,
2183 None,
2184 HashMap::new(),
2185 HashMap::new(),
2186 bp_global,
2187 )
2188 .await
2189 .expect("attach_with_ids ok");
2190 let session = engine
2191 .with_state("test.find_session", |s| {
2192 s.sessions
2193 .values()
2194 .find(|sess| sess.token_fp == token.fingerprint())
2195 .cloned()
2196 })
2197 .await
2198 .expect("with_state ok")
2199 .expect("session present after attach_with_ids");
2200 engine.resolve_operator_info(&session, "agent-x").await
2201 }
2202
2203 #[tokio::test]
2204 async fn explicit_some_automate_outranks_bp_global_main_ai() {
2205 // Runtime Global explicitly requests Automate; bp_global is MainAi.
2206 // The explicit `Some(Automate)` must win — this is exactly the case
2207 // the old `== OperatorKind::default()` convention got wrong (it
2208 // could not tell "explicitly Automate" from "unspecified" and would
2209 // have let `bp_global` (MainAi) take over instead).
2210 let info =
2211 attach_and_resolve(Some(OperatorKind::Automate), Some(OperatorKind::MainAi)).await;
2212 assert_eq!(
2213 info.kind,
2214 OperatorKind::Automate,
2215 "explicit Some(Automate) runtime_global must outrank bp_global MainAi"
2216 );
2217 }
2218
2219 #[tokio::test]
2220 async fn none_lets_bp_global_main_ai_win() {
2221 // Runtime Global left unspecified (`None`); bp_global is MainAi.
2222 // With nothing more specific set, `bp_global` must decide.
2223 let info = attach_and_resolve(None, Some(OperatorKind::MainAi)).await;
2224 assert_eq!(
2225 info.kind,
2226 OperatorKind::MainAi,
2227 "None runtime_global must let bp_global MainAi win"
2228 );
2229 }
2230}
2231
2232/// issue #13 run_id propagation: `dispatch_attempt_with`'s `run_id` param
2233/// must land in `Ctx.meta.runtime["run_id"]` (the same slot pattern as the
2234/// pre-existing `worker_handle`), or be omitted entirely when `None`. Same
2235/// `CtxProbe` shape as `middleware::worker_binding`'s test module — an
2236/// inner `SpawnerAdapter` that snapshots the `Ctx` it was called with and
2237/// fails the spawn (only the ctx snapshot matters here).
2238#[cfg(test)]
2239mod dispatch_attempt_with_run_id_tests {
2240 use super::*;
2241 use crate::worker::adapter::{SpawnError, SpawnerAdapter};
2242 use crate::worker::Worker;
2243 use std::sync::Mutex as StdMutex;
2244
2245 struct CtxProbe {
2246 seen: Arc<StdMutex<Option<Ctx>>>,
2247 }
2248
2249 #[async_trait::async_trait]
2250 impl SpawnerAdapter for CtxProbe {
2251 async fn spawn(
2252 &self,
2253 _engine: &Engine,
2254 ctx: &Ctx,
2255 _task_id: StepId,
2256 _attempt: u32,
2257 _token: CapToken,
2258 ) -> Result<Box<dyn Worker>, SpawnError> {
2259 *self.seen.lock().unwrap() = Some(ctx.clone());
2260 Err(SpawnError::Internal("probe stop".into()))
2261 }
2262 }
2263
2264 async fn dispatch_with_probe(run_id: Option<&RunId>) -> Ctx {
2265 let engine = Engine::new(EngineCfg::default());
2266 let token = engine
2267 .attach("ut-op", Role::Operator, Duration::from_secs(30))
2268 .await
2269 .expect("attach");
2270 let tid = engine
2271 .start_task(
2272 &token,
2273 TaskSpec {
2274 agent: "probe".into(),
2275 initial_directive: "hi".into(),
2276 step_ctx: None,
2277 },
2278 )
2279 .await
2280 .expect("start_task");
2281 let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
2282 let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
2283 // The probe always errors the spawn (`SpawnError::Internal`); we
2284 // only care about the `Ctx` snapshot it captured, so the dispatch
2285 // outcome itself (`Err`) is discarded.
2286 let _ = engine
2287 .dispatch_attempt_with(&token, &tid, &spawner, run_id)
2288 .await;
2289 let captured = seen.lock().unwrap().clone();
2290 captured.expect("inner ctx captured")
2291 }
2292
2293 #[tokio::test]
2294 async fn run_id_lands_in_ctx_meta_runtime_when_some() {
2295 let run_id = RunId::new();
2296 let observed = dispatch_with_probe(Some(&run_id)).await;
2297 assert_eq!(
2298 observed.meta.runtime.get("run_id").and_then(|v| v.as_str()),
2299 Some(run_id.as_str()),
2300 "ctx.meta.runtime[\"run_id\"] must carry the run_id passed to dispatch_attempt_with"
2301 );
2302 }
2303
2304 #[tokio::test]
2305 async fn run_id_key_absent_when_none() {
2306 let observed = dispatch_with_probe(None).await;
2307 assert!(
2308 !observed.meta.runtime.contains_key("run_id"),
2309 "no run_id key must be injected when dispatch_attempt_with is called with None"
2310 );
2311 }
2312}
2313
2314/// GH #21 Phase 2: `TaskSpec.step_ctx` must land in
2315/// `Ctx.meta.runtime[STEP_CTX_KEY]` — re-read from the spec on EVERY
2316/// attempt (the prep closure re-reads `task.spec.step_ctx` every call, not
2317/// caching it once at `start_task`), so a retry (attempt 2) carries it
2318/// too. Same `CtxProbe` shape as `dispatch_attempt_with_run_id_tests`.
2319#[cfg(test)]
2320mod dispatch_attempt_with_step_ctx_tests {
2321 use super::*;
2322 use crate::worker::adapter::{SpawnError, SpawnerAdapter};
2323 use crate::worker::Worker;
2324 use std::sync::Mutex as StdMutex;
2325
2326 struct CtxProbe {
2327 seen: Arc<StdMutex<Option<Ctx>>>,
2328 }
2329
2330 #[async_trait::async_trait]
2331 impl SpawnerAdapter for CtxProbe {
2332 async fn spawn(
2333 &self,
2334 _engine: &Engine,
2335 ctx: &Ctx,
2336 _task_id: StepId,
2337 _attempt: u32,
2338 _token: CapToken,
2339 ) -> Result<Box<dyn Worker>, SpawnError> {
2340 *self.seen.lock().unwrap() = Some(ctx.clone());
2341 Err(SpawnError::Internal("probe stop".into()))
2342 }
2343 }
2344
2345 #[tokio::test]
2346 async fn step_ctx_lands_in_ctx_meta_runtime_on_attempt_1_and_2() {
2347 let engine = Engine::new(EngineCfg::default());
2348 let token = engine
2349 .attach("ut-op", Role::Operator, Duration::from_secs(30))
2350 .await
2351 .expect("attach");
2352 let tid = engine
2353 .start_task(
2354 &token,
2355 TaskSpec {
2356 agent: "probe".into(),
2357 initial_directive: "hi".into(),
2358 step_ctx: Some(serde_json::json!({ "work_dir": "/step" })),
2359 },
2360 )
2361 .await
2362 .expect("start_task");
2363 let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
2364 let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
2365
2366 // The probe always errors the spawn; only the ctx snapshot matters.
2367 let _ = engine
2368 .dispatch_attempt_with(&token, &tid, &spawner, None)
2369 .await;
2370 let first = seen
2371 .lock()
2372 .unwrap()
2373 .clone()
2374 .expect("attempt 1 ctx captured");
2375 assert_eq!(
2376 first.meta.runtime.get(STEP_CTX_KEY),
2377 Some(&serde_json::json!({ "work_dir": "/step" })),
2378 "attempt 1 must carry TaskSpec.step_ctx in ctx.meta.runtime[STEP_CTX_KEY]"
2379 );
2380
2381 let _ = engine
2382 .dispatch_attempt_with(&token, &tid, &spawner, None)
2383 .await;
2384 let second = seen
2385 .lock()
2386 .unwrap()
2387 .clone()
2388 .expect("attempt 2 ctx captured");
2389 assert_eq!(
2390 second.meta.runtime.get(STEP_CTX_KEY),
2391 Some(&serde_json::json!({ "work_dir": "/step" })),
2392 "attempt 2 (retry) must ALSO carry TaskSpec.step_ctx — prep re-reads the spec every attempt"
2393 );
2394 }
2395
2396 #[tokio::test]
2397 async fn step_ctx_key_absent_when_none() {
2398 let engine = Engine::new(EngineCfg::default());
2399 let token = engine
2400 .attach("ut-op", Role::Operator, Duration::from_secs(30))
2401 .await
2402 .expect("attach");
2403 let tid = engine
2404 .start_task(
2405 &token,
2406 TaskSpec {
2407 agent: "probe".into(),
2408 initial_directive: "hi".into(),
2409 step_ctx: None,
2410 },
2411 )
2412 .await
2413 .expect("start_task");
2414 let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
2415 let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
2416 let _ = engine
2417 .dispatch_attempt_with(&token, &tid, &spawner, None)
2418 .await;
2419 let observed = seen.lock().unwrap().clone().expect("ctx captured");
2420 assert!(
2421 !observed.meta.runtime.contains_key(STEP_CTX_KEY),
2422 "no step_ctx key must be injected when TaskSpec.step_ctx is None"
2423 );
2424 }
2425}
2426
2427// ─── issue #18: `TaskSpec.initial_directive` `Value` pass-through ──────────
2428#[cfg(test)]
2429mod initial_directive_value_passthrough_tests {
2430 use super::*;
2431
2432 async fn seeded_engine(initial_directive: Value) -> (Engine, CapToken, StepId) {
2433 let engine = Engine::new(EngineCfg::default());
2434 let op_token = engine
2435 .attach("ut-op", Role::Operator, Duration::from_secs(30))
2436 .await
2437 .expect("attach");
2438 let task_id = engine
2439 .start_task(
2440 &op_token,
2441 TaskSpec {
2442 agent: "planner".to_string(),
2443 initial_directive,
2444 step_ctx: None,
2445 },
2446 )
2447 .await
2448 .expect("start_task");
2449 (engine, op_token, task_id)
2450 }
2451
2452 /// Mint + register a `Role::Worker` token the same way
2453 /// `dispatch_attempt_with` does — `fetch_prompt` is worker-verb-gated.
2454 async fn mint_worker_token(engine: &Engine, task_id: &StepId) -> CapToken {
2455 let worker_token = engine.signer().session(
2456 format!("worker-of-{task_id}"),
2457 Role::Worker,
2458 vec!["*".into()],
2459 Duration::from_secs(600),
2460 );
2461 let fp = worker_token.fingerprint();
2462 let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
2463 engine
2464 .with_state("test.mint_worker", move |s| {
2465 s.tokens.insert(fp, record);
2466 })
2467 .await
2468 .expect("mint worker token");
2469 worker_token
2470 }
2471
2472 /// `EngineDispatcher::dispatch` no longer stringifies the evaluated
2473 /// `Step.in` value before seeding `TaskSpec.initial_directive` — an
2474 /// Object seed must round-trip through `start_task` /
2475 /// `read_task_state` byte-for-byte as the same `Value::Object`, not a
2476 /// JSON-stringified `Value::String`.
2477 #[tokio::test]
2478 async fn object_seed_passes_through_task_spec_unchanged() {
2479 let seed = serde_json::json!({"key": "value"});
2480 let (engine, token, task_id) = seeded_engine(seed.clone()).await;
2481 let state = engine
2482 .read_task_state(&token, &task_id)
2483 .await
2484 .expect("read_task_state");
2485 assert_eq!(
2486 state.spec.initial_directive, seed,
2487 "TaskSpec.initial_directive must equal the raw Object seed, not a stringified copy"
2488 );
2489 }
2490
2491 /// `Engine::fetch_prompt` returns the `Value` end-to-end (issue #18):
2492 /// an Object seed stays a `Value::Object` and is not stringified in
2493 /// the engine layer. The Worker HTTP boundary
2494 /// (`fetch_worker_payload*`) is what performs the render down to a
2495 /// JSON literal `String` for `WorkerPayload.prompt`.
2496 #[tokio::test]
2497 async fn object_seed_passes_through_fetch_prompt_as_value() {
2498 let seed = serde_json::json!({"key": "value"});
2499 let (engine, _token, task_id) = seeded_engine(seed.clone()).await;
2500 let worker_token = mint_worker_token(&engine, &task_id).await;
2501 let prompt = engine
2502 .fetch_prompt(&worker_token, &task_id)
2503 .await
2504 .expect("fetch_prompt");
2505 assert_eq!(
2506 prompt, seed,
2507 "fetch_prompt must return the raw Object Value, not a stringified copy"
2508 );
2509 }
2510
2511 /// The Worker HTTP boundary is the render point: `fetch_worker_payload*`
2512 /// coerces the stored `Value` down to `WorkerPayload.prompt: String`
2513 /// (JSON-literal shape for non-strings). Verifies the boundary render
2514 /// stays intact for an Object seed.
2515 #[tokio::test]
2516 async fn object_seed_renders_as_json_literal_at_worker_payload_boundary() {
2517 let seed = serde_json::json!({"key": "value"});
2518 let (engine, _token, task_id) = seeded_engine(seed).await;
2519 let worker_token = mint_worker_token(&engine, &task_id).await;
2520 let payload = engine
2521 .fetch_worker_payload(&worker_token, &task_id)
2522 .await
2523 .expect("fetch_worker_payload");
2524 assert_eq!(
2525 payload.prompt, r#"{"key":"value"}"#,
2526 "WorkerPayload.prompt must be the JSON literal String render of the Value seed"
2527 );
2528 }
2529
2530 /// A `String` seed is unaffected — still passes through verbatim, both
2531 /// as the `TaskSpec.initial_directive` `Value` and as the Worker
2532 /// `fetch_prompt` return (issue #18 Invariant 2).
2533 #[tokio::test]
2534 async fn string_seed_passes_through_unchanged() {
2535 let (engine, token, task_id) = seeded_engine(serde_json::json!("do the thing")).await;
2536 let state = engine
2537 .read_task_state(&token, &task_id)
2538 .await
2539 .expect("read_task_state");
2540 assert_eq!(
2541 state.spec.initial_directive,
2542 serde_json::json!("do the thing")
2543 );
2544 let worker_token = mint_worker_token(&engine, &task_id).await;
2545 let prompt = engine
2546 .fetch_prompt(&worker_token, &task_id)
2547 .await
2548 .expect("fetch_prompt");
2549 assert_eq!(prompt, serde_json::json!("do the thing"));
2550 }
2551}
2552
2553/// subtask-4 / ST2 rework: `submit_output` / `submit_worker_result_trusted`'s
2554/// submit-time projection sink (`Engine::materialize_final_submission`) —
2555/// the Data-plane `OutputStore` dual-write plus the
2556/// `FileProjectionAdapter`-backed file materialize, both fail-open. See
2557/// the subtask-4 Tests this module covers inline on each test.
2558#[cfg(test)]
2559mod submit_time_projection_sink_tests {
2560 use super::*;
2561 use crate::core::agent_context::AgentContextView;
2562 use crate::store::output::{ContentRef, InMemoryOutputStore, OutputEvent};
2563
2564 /// Starts a task under `agent`, returning `(engine, op_token, task_id,
2565 /// worker_token)` — same helper shape as the sibling test modules
2566 /// above (`initial_directive_value_passthrough_tests::seeded_engine` /
2567 /// `mint_worker_token`), duplicated locally per this file's
2568 /// established per-module convention.
2569 async fn seeded_task(agent: &str) -> (Engine, CapToken, StepId, CapToken) {
2570 let engine = Engine::new(EngineCfg::default());
2571 let op_token = engine
2572 .attach("ut-op", Role::Operator, Duration::from_secs(30))
2573 .await
2574 .expect("attach");
2575 let task_id = engine
2576 .start_task(
2577 &op_token,
2578 TaskSpec {
2579 agent: agent.to_string(),
2580 initial_directive: Value::String("go".into()),
2581 step_ctx: None,
2582 },
2583 )
2584 .await
2585 .expect("start_task");
2586 let worker_token = engine.signer().session(
2587 format!("worker-of-{task_id}"),
2588 Role::Worker,
2589 vec!["*".into()],
2590 Duration::from_secs(600),
2591 );
2592 let fp = worker_token.fingerprint();
2593 let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
2594 engine
2595 .with_state("test.mint_worker", move |s| {
2596 s.tokens.insert(fp, record);
2597 })
2598 .await
2599 .expect("mint worker token");
2600 (engine, op_token, task_id, worker_token)
2601 }
2602
2603 /// Seeds `EngineState.agent_ctx[(task_id, attempt)].view` directly —
2604 /// the same snapshot `AgentContextMiddleware` writes at spawn time
2605 /// (see its module doc), stood up here without the full spawner
2606 /// stack so these tests can exercise `submit_output` in isolation.
2607 async fn seed_agent_context(engine: &Engine, task_id: &StepId, attempt: u32, work_dir: &str) {
2608 let task_id = task_id.clone();
2609 let work_dir = work_dir.to_string();
2610 engine
2611 .with_state("test.seed_agent_context", move |s| {
2612 s.agent_ctx.insert(
2613 (task_id, attempt),
2614 crate::core::state::AgentCtxEntry {
2615 view: AgentContextView {
2616 work_dir: Some(work_dir),
2617 ..Default::default()
2618 },
2619 policy: Default::default(),
2620 },
2621 );
2622 })
2623 .await
2624 .expect("seed agent_ctx");
2625 }
2626
2627 /// GH #27 (follow-up to #23): seeds `EngineState.agent_ctx` with an
2628 /// arbitrary `work_dir` / `project_root` pair (either may be `None`),
2629 /// unlike [`seed_agent_context`] (which only ever sets `work_dir`) —
2630 /// lets these tests exercise `ProjectionPlacement::resolve_root`'s
2631 /// fallback in both directions.
2632 async fn seed_agent_context_roots(
2633 engine: &Engine,
2634 task_id: &StepId,
2635 attempt: u32,
2636 work_dir: Option<&str>,
2637 project_root: Option<&str>,
2638 ) {
2639 let task_id = task_id.clone();
2640 let work_dir = work_dir.map(str::to_string);
2641 let project_root = project_root.map(str::to_string);
2642 engine
2643 .with_state("test.seed_agent_context_roots", move |s| {
2644 s.agent_ctx.insert(
2645 (task_id, attempt),
2646 crate::core::state::AgentCtxEntry {
2647 view: AgentContextView {
2648 work_dir,
2649 project_root,
2650 ..Default::default()
2651 },
2652 policy: Default::default(),
2653 },
2654 );
2655 })
2656 .await
2657 .expect("seed agent_ctx");
2658 }
2659
2660 /// GH #27 (follow-up to #23): seeds `EngineState.projection_placements`
2661 /// directly — the same snapshot `EngineDispatcher::dispatch` stashes
2662 /// at dispatch time (mirroring [`seed_step_naming`]'s contract) — so
2663 /// these tests can exercise a declared `ProjectionPlacement` without
2664 /// driving a real `Compiler::compile`.
2665 async fn seed_projection_placement(
2666 engine: &Engine,
2667 task_id: &StepId,
2668 placement: crate::core::projection_placement::ProjectionPlacement,
2669 ) {
2670 let task_id = task_id.clone();
2671 let placement = Arc::new(placement);
2672 engine
2673 .with_state("test.seed_projection_placement", move |s| {
2674 s.projection_placements.insert(task_id, placement);
2675 })
2676 .await
2677 .expect("seed projection_placements");
2678 }
2679
2680 /// GH #23 subtask-2: builds a fixture
2681 /// [`crate::core::step_naming::StepNaming`] table declaring `producer`
2682 /// → `canonical` (`AgentMeta.projection_name`), then seeds it into
2683 /// `EngineState.step_namings` for `task_id` — the same snapshot
2684 /// `EngineDispatcher::dispatch` stashes at dispatch time
2685 /// (`blueprint.rs`'s "construct once, read many" contract), stood up
2686 /// here without the full Blueprint-compile path so these tests can
2687 /// exercise the canonical-sink resolution in isolation.
2688 async fn seed_step_naming(engine: &Engine, task_id: &StepId, producer: &str, canonical: &str) {
2689 use crate::blueprint::{
2690 current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
2691 CompilerHints, CompilerStrategy,
2692 };
2693 use crate::core::step_naming::StepNaming;
2694 use mlua_flow_ir::{Expr, Node};
2695
2696 let flow = Node::Step {
2697 ref_: producer.to_string(),
2698 in_: Expr::Path {
2699 at: "$.in".to_string(),
2700 },
2701 out: Expr::Path {
2702 at: format!("$.{producer}_out"),
2703 },
2704 };
2705 let bp = Blueprint {
2706 schema_version: current_schema_version(),
2707 id: "sink-canonical-ut".into(),
2708 flow,
2709 agents: vec![AgentDef {
2710 name: producer.to_string(),
2711 kind: AgentKind::RustFn,
2712 spec: serde_json::json!({ "fn_id": producer }),
2713 profile: None,
2714 meta: Some(AgentMeta {
2715 projection_name: Some(canonical.to_string()),
2716 ..Default::default()
2717 }),
2718 }],
2719 operators: vec![],
2720 metas: vec![],
2721 hints: CompilerHints::default(),
2722 strategy: CompilerStrategy::default(),
2723 metadata: BlueprintMetadata::default(),
2724 spawner_hints: Default::default(),
2725 default_agent_kind: AgentKind::Operator,
2726 default_operator_kind: None,
2727 default_init_ctx: None,
2728 default_agent_ctx: None,
2729 default_context_policy: None,
2730 projection_placement: None,
2731 };
2732 let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
2733 assert!(warnings.is_empty(), "single-step fixture has no collisions");
2734 let naming = Arc::new(naming);
2735 let task_id = task_id.clone();
2736 engine
2737 .with_state("test.seed_step_naming", move |s| {
2738 s.step_namings.insert(task_id, naming);
2739 })
2740 .await
2741 .expect("seed step_namings");
2742 }
2743
2744 fn final_event(value: Value, ok: bool) -> crate::worker::output::OutputEvent {
2745 crate::worker::output::OutputEvent::Final {
2746 content: crate::worker::output::ContentRef::Inline { value },
2747 ok,
2748 }
2749 }
2750
2751 /// Subtask 4 Test #2: `submit_output`'s `Final` writes
2752 /// `<root>/workspace/tasks/<task_id>/ctx/<agent>.md`, content matching
2753 /// the submitted value.
2754 #[tokio::test]
2755 async fn submit_output_final_materializes_file_when_work_dir_resolved() {
2756 let dir = tempfile::TempDir::new().unwrap();
2757 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
2758 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
2759
2760 engine
2761 .submit_output(
2762 &worker_token,
2763 &task_id,
2764 1,
2765 final_event(serde_json::json!({"plan": "do it"}), true),
2766 )
2767 .await
2768 .expect("submit_output");
2769
2770 let expected_file = dir
2771 .path()
2772 .join("workspace/tasks")
2773 .join(task_id.as_str())
2774 .join("ctx/planner.md");
2775 assert!(
2776 expected_file.exists(),
2777 "materialized submission file missing at {expected_file:?}"
2778 );
2779 let body = std::fs::read_to_string(expected_file).unwrap();
2780 assert!(body.contains(r#""plan": "do it""#), "body: {body}");
2781 }
2782
2783 /// Subtask 4 Test #3: `work_dir` unresolved (no `agent_ctx`
2784 /// snapshot for this `(task_id, attempt)`) — submit still succeeds,
2785 /// fail-open, no file.
2786 #[tokio::test]
2787 async fn submit_output_final_skips_file_when_root_unresolved() {
2788 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
2789 // No seed_agent_context call — root is unresolved.
2790
2791 let result = engine
2792 .submit_output(
2793 &worker_token,
2794 &task_id,
2795 1,
2796 final_event(serde_json::json!("hi"), true),
2797 )
2798 .await;
2799 assert!(
2800 result.is_ok(),
2801 "submit must succeed even with no resolvable root (fail-open, Invariant 1)"
2802 );
2803 }
2804
2805 /// Subtask 4 Test #4 (file half): re-submitting under the same
2806 /// `(task_id, agent)` overwrites the materialized file with the
2807 /// latest value.
2808 #[tokio::test]
2809 async fn resubmit_overwrites_materialized_file_with_latest() {
2810 let dir = tempfile::TempDir::new().unwrap();
2811 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
2812 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
2813
2814 engine
2815 .submit_output(
2816 &worker_token,
2817 &task_id,
2818 1,
2819 final_event(serde_json::json!("first"), true),
2820 )
2821 .await
2822 .expect("first submit");
2823 engine
2824 .submit_output(
2825 &worker_token,
2826 &task_id,
2827 1,
2828 final_event(serde_json::json!("second"), true),
2829 )
2830 .await
2831 .expect("second submit");
2832
2833 let expected_file = dir
2834 .path()
2835 .join("workspace/tasks")
2836 .join(task_id.as_str())
2837 .join("ctx/planner.md");
2838 let body = std::fs::read_to_string(expected_file).unwrap();
2839 assert!(body.contains("second"), "body must reflect latest: {body}");
2840 assert!(
2841 !body.contains("first"),
2842 "body must not carry the stale value: {body}"
2843 );
2844 }
2845
2846 /// GH #27 (follow-up to #23): the byte-compat default
2847 /// `ProjectionPlacement` (`root_preference = WorkDir`) falls back to
2848 /// `project_root` when `work_dir` is absent — the same fallback
2849 /// [`crate::core::projection_placement::ProjectionPlacement::resolve_root`]
2850 /// now performs for every one of the "3 path" call sites, this one
2851 /// exercised at the submit-sink layer.
2852 #[tokio::test]
2853 async fn submit_output_final_falls_back_to_project_root_when_work_dir_absent() {
2854 let dir = tempfile::TempDir::new().unwrap();
2855 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
2856 seed_agent_context_roots(
2857 &engine,
2858 &task_id,
2859 1,
2860 None,
2861 Some(&dir.path().to_string_lossy()),
2862 )
2863 .await;
2864
2865 engine
2866 .submit_output(
2867 &worker_token,
2868 &task_id,
2869 1,
2870 final_event(serde_json::json!({"plan": "via project_root"}), true),
2871 )
2872 .await
2873 .expect("submit_output");
2874
2875 let expected_file = dir
2876 .path()
2877 .join("workspace/tasks")
2878 .join(task_id.as_str())
2879 .join("ctx/planner.md");
2880 assert!(
2881 expected_file.exists(),
2882 "materialized submission file missing at {expected_file:?} \
2883 (work_dir absent must fall back to project_root)"
2884 );
2885 }
2886
2887 /// GH #27 (follow-up to #23): a declared `ProjectionPlacement`
2888 /// (`root_preference = ProjectRoot`, custom `dir_template`) changes
2889 /// BOTH which root is preferred (project_root wins even though
2890 /// work_dir is also present) AND the target directory layout — proof
2891 /// the submit sink consults the snapshotted resolver rather than a
2892 /// hardcoded layout.
2893 #[tokio::test]
2894 async fn submit_output_final_uses_declared_projection_placement() {
2895 let work_dir = tempfile::TempDir::new().unwrap();
2896 let project_root = tempfile::TempDir::new().unwrap();
2897 let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
2898 seed_agent_context_roots(
2899 &engine,
2900 &task_id,
2901 1,
2902 Some(&work_dir.path().to_string_lossy()),
2903 Some(&project_root.path().to_string_lossy()),
2904 )
2905 .await;
2906 seed_projection_placement(
2907 &engine,
2908 &task_id,
2909 crate::core::projection_placement::ProjectionPlacement {
2910 root_preference: crate::core::projection_placement::RootPreference::ProjectRoot,
2911 dir_template: "custom/{task_id}/out".to_string(),
2912 },
2913 )
2914 .await;
2915
2916 engine
2917 .submit_output(
2918 &worker_token,
2919 &task_id,
2920 1,
2921 final_event(serde_json::json!({"plan": "via custom placement"}), true),
2922 )
2923 .await
2924 .expect("submit_output");
2925
2926 let expected_file = project_root
2927 .path()
2928 .join("custom")
2929 .join(task_id.as_str())
2930 .join("out/planner.md");
2931 assert!(
2932 expected_file.exists(),
2933 "materialized submission file missing at custom placement target {expected_file:?}"
2934 );
2935 let unexpected_file = work_dir
2936 .path()
2937 .join("workspace/tasks")
2938 .join(task_id.as_str())
2939 .join("ctx/planner.md");
2940 assert!(
2941 !unexpected_file.exists(),
2942 "declared root_preference=ProjectRoot must not fall back to work_dir: {unexpected_file:?}"
2943 );
2944 }
2945
2946 /// Subtask 4 Invariant 3 / crux requirement #3: when
2947 /// [`Engine::set_output_store`] wires a Data-plane [`crate::store::output::OutputStore`],
2948 /// `submit_output`'s `Final` dual-writes into it under
2949 /// `producer_agent = TaskState.spec.agent` — the store becomes
2950 /// queryable via `get_latest_by_name`, independent of whether a root
2951 /// resolved for the file half.
2952 #[tokio::test]
2953 async fn submit_output_final_dual_writes_into_configured_output_store() {
2954 let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
2955 let data_store: Arc<dyn crate::store::output::OutputStore> =
2956 Arc::new(InMemoryOutputStore::new());
2957 engine.set_output_store(data_store.clone());
2958
2959 engine
2960 .submit_output(
2961 &worker_token,
2962 &task_id,
2963 1,
2964 final_event(serde_json::json!({"verdict": "pass"}), true),
2965 )
2966 .await
2967 .expect("submit_output");
2968
2969 let record = data_store
2970 .get_latest_by_name("reviewer")
2971 .await
2972 .expect("dual-written record");
2973 match record.event {
2974 OutputEvent::Final { content, ok } => {
2975 assert!(ok);
2976 match content {
2977 ContentRef::Inline { value } => {
2978 assert_eq!(value, serde_json::json!({"verdict": "pass"}));
2979 }
2980 other => panic!("expected Inline content, got {other:?}"),
2981 }
2982 }
2983 other => panic!("expected Final event, got {other:?}"),
2984 }
2985 }
2986
2987 /// `submit_worker_result_trusted` (the `/v1/worker/submit` short-handle
2988 /// path) triggers the exact same sink as `submit_output` — parity
2989 /// across both worker-submit entry points.
2990 #[tokio::test]
2991 async fn submit_worker_result_trusted_also_triggers_projection_sink() {
2992 let dir = tempfile::TempDir::new().unwrap();
2993 let (engine, _op, task_id, _worker_token) = seeded_task("planner").await;
2994 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
2995 let data_store: Arc<dyn crate::store::output::OutputStore> =
2996 Arc::new(InMemoryOutputStore::new());
2997 engine.set_output_store(data_store.clone());
2998
2999 engine
3000 .submit_worker_result_trusted(&task_id, 1, serde_json::json!("trusted-value"), true)
3001 .await
3002 .expect("submit_worker_result_trusted");
3003
3004 let expected_file = dir
3005 .path()
3006 .join("workspace/tasks")
3007 .join(task_id.as_str())
3008 .join("ctx/planner.md");
3009 assert!(expected_file.exists());
3010 let record = data_store
3011 .get_latest_by_name("planner")
3012 .await
3013 .expect("dual-written record");
3014 assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
3015 }
3016
3017 /// GH #23 subtask-2 (canonical sink): a declared `projection_name`
3018 /// (`AgentMeta.projection_name`, surfaced via `StepNaming`) redirects
3019 /// `submit_output`'s Final canonical sink — both the Data-plane
3020 /// dual-write name and the materialized file stem resolve to the
3021 /// canonical name, not the raw `producer_agent`.
3022 #[tokio::test]
3023 async fn submit_output_final_uses_canonical_name_when_step_naming_declares_one() {
3024 let dir = tempfile::TempDir::new().unwrap();
3025 let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
3026 seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
3027 seed_step_naming(&engine, &task_id, "reviewer", "verdict-final").await;
3028 let data_store: Arc<dyn crate::store::output::OutputStore> =
3029 Arc::new(InMemoryOutputStore::new());
3030 engine.set_output_store(data_store.clone());
3031
3032 engine
3033 .submit_output(
3034 &worker_token,
3035 &task_id,
3036 1,
3037 final_event(serde_json::json!({"verdict": "pass"}), true),
3038 )
3039 .await
3040 .expect("submit_output");
3041
3042 let record = data_store
3043 .get_latest_by_name("verdict-final")
3044 .await
3045 .expect("dual-written record under canonical name");
3046 assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
3047 assert!(
3048 data_store.get_latest_by_name("reviewer").await.is_err(),
3049 "raw producer_agent name must not be written once canonical resolves"
3050 );
3051
3052 let expected_file = dir
3053 .path()
3054 .join("workspace/tasks")
3055 .join(task_id.as_str())
3056 .join("ctx/verdict-final.md");
3057 assert!(
3058 expected_file.exists(),
3059 "materialized file stem must be canonical at {expected_file:?}"
3060 );
3061 }
3062
3063 /// GH #23 subtask-2: no `StepNaming` table snapshotted for this
3064 /// `task_id` (the pre-GH-#23 / no-`with_step_naming` path) is a
3065 /// defensive fail-open — the canonical sink falls back to the raw
3066 /// `producer_agent`, byte-identical to
3067 /// `submit_output_final_dual_writes_into_configured_output_store`
3068 /// above (which never calls `seed_step_naming`).
3069 #[tokio::test]
3070 async fn submit_output_final_falls_back_to_producer_agent_when_no_step_naming_table() {
3071 let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
3072 let data_store: Arc<dyn crate::store::output::OutputStore> =
3073 Arc::new(InMemoryOutputStore::new());
3074 engine.set_output_store(data_store.clone());
3075
3076 engine
3077 .submit_output(
3078 &worker_token,
3079 &task_id,
3080 1,
3081 final_event(serde_json::json!({"verdict": "pass"}), true),
3082 )
3083 .await
3084 .expect("submit_output");
3085
3086 let record = data_store
3087 .get_latest_by_name("reviewer")
3088 .await
3089 .expect("fail-open dual-write under raw producer_agent name");
3090 assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
3091 }
3092
3093 /// GH #23 subtask-2 (Layer 2): `OutputStore::get_latest_by_name_in_run`
3094 /// resolves the value `submit_output` dual-wrote for this exact
3095 /// `(task_id, attempt)` run, independent of `get_latest_by_name`'s
3096 /// cross-Run race (two Runs sharing a producer name never bleed into
3097 /// each other through the Run-scoped accessor).
3098 #[tokio::test]
3099 async fn submit_output_final_is_resolvable_via_run_scoped_lookup() {
3100 let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
3101 let data_store: Arc<dyn crate::store::output::OutputStore> =
3102 Arc::new(InMemoryOutputStore::new());
3103 engine.set_output_store(data_store.clone());
3104
3105 engine
3106 .submit_output(
3107 &worker_token,
3108 &task_id,
3109 1,
3110 final_event(serde_json::json!({"verdict": "pass"}), true),
3111 )
3112 .await
3113 .expect("submit_output");
3114
3115 let record = data_store
3116 .get_latest_by_name_in_run(task_id.as_str(), 1, "reviewer")
3117 .await
3118 .expect("run-scoped lookup resolves the dual-written record");
3119 assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
3120
3121 // A different attempt of the same task must not resolve — the
3122 // Run-scoped lookup does not fall back across attempts.
3123 assert!(
3124 data_store
3125 .get_latest_by_name_in_run(task_id.as_str(), 2, "reviewer")
3126 .await
3127 .is_err(),
3128 "a different attempt must not resolve the same-named record"
3129 );
3130 }
3131}