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