Skip to main content

mlua_swarm/core/
engine.rs

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