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