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