Skip to main content

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