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