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