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