Skip to main content

mlua_swarm/application/
enhance.rs

1//! `EnhanceApplication` — the dispatcher for the `POST /v1/issues`
2//! path.
3//!
4//! The entry point (`POST /v1/issues`) only does `IssueStore.create` —
5//! a synchronous enqueue. The actual dispatch is drained by a
6//! consumer loop calling `tick()`:
7//!
8//! ```text
9//! POST /v1/issues ──→ IssueStore (queue)
10//!                            ↓
11//!     consumer loop (tokio::spawn) ── tick() ──┐
12//!                                              ↓
13//!                  IssueStore.pop_pending + EnhanceSettingStore.get
14//!                                              ↓
15//!                  BPStore.read_head(setting.blueprint.id)      (fetched on use)
16//!                                              ↓
17//!                  TaskLaunchService.launch(...)                (engine bind + attach + start_task)
18//!                       the drain stops waiting on this after
19//!                       EnhanceSetting.ttl_secs                  (see the ceiling block below)
20//! ```
21//!
22//! One tick is one epoch. `EnhanceSetting.ttl_secs` is **not** a ceiling on
23//! that epoch: it is a ceiling on how long the drain waits for one call —
24//! `TaskLaunchService::launch` — and nothing else. Two consequences, both
25//! load-bearing, both spelled out in the ceiling block in
26//! [`EnhanceApplication::dispatch_one`]:
27//!
28//! - **It bounds the wait, which is the thing it was built to bound.** The
29//!   knob exists so the Swarm can give up on an Operator that stopped
30//!   answering (model §4.4 **R5** places the bound in infra, not in the
31//!   model), and that wait is an await point, so dropping the launch future
32//!   releases it. It does **not** stop a worker already running in an
33//!   in-process lane: those run in `tokio::spawn`ed tasks that this future
34//!   does not own, and nothing in this repository fires the
35//!   `CancellationToken` they select on. The drain is unwedged either way;
36//!   the work may still be running behind it, which is what the reason text
37//!   tells the operator to check before re-posting.
38//! - **It does not span the epoch.** The store calls on either side of
39//!   `launch` — `setting_store.get`, `resolve_blueprint`,
40//!   `bp_store.read_head`, `bp_store.write_new`, `log_store.append`, and
41//!   `issue_store.update_status` in both `tick` arms — are outside it and
42//!   are bounded by nothing.
43//!
44//! So the ceiling removes exactly one wedge — a `patch-spawner` whose call
45//! never returns — and leaves every other way to stall the single-threaded
46//! drain in place.
47//!
48//! Current scope:
49//!
50//! - Engine task-completion → `Issue.update_status` is a carry.
51//! - Setting `VersionSelector` (`Fixed` / `Latest` / `SemverReq`) is
52//!   a carry — today we always use `BPStore.read_head`.
53//! - The agent-selection convention is
54//!   `setting.blueprint.agents.first().name`.
55
56use super::semver_resolve::SemverResolveError;
57use super::{Application, VersionSelector};
58use crate::blueprint::store::{
59    blueprint_version, BlueprintEpoch, BlueprintId, BlueprintStore, BlueprintStoreError,
60    CommitMetadata, ContentHash, Traced,
61};
62use crate::blueprint::{AgentDef, Blueprint};
63use crate::core::errors::EngineError;
64use crate::enhance::blueprint::AG_PATCH_SPAWNER;
65use crate::service::{TaskLaunchError, TaskLaunchInput, TaskLaunchOutput, TaskLaunchService};
66use crate::store::enhance_log::{
67    EnhanceLogEntry, EnhanceLogStore, EnhanceLogStoreError, VerdictSummary,
68};
69use crate::store::enhance_setting::{
70    EnhanceSettingId, EnhanceSettingStore, EnhanceSettingStoreError,
71};
72use crate::store::issue::{IssueId, IssuePayload, IssueStatus, IssueStore, IssueStoreError};
73use crate::types::Role;
74use async_trait::async_trait;
75use std::sync::Arc;
76use std::time::Duration;
77use thiserror::Error;
78
79/// Failure modes of [`EnhanceApplication::tick`] and the internal
80/// `dispatch_one` step it wraps.
81#[derive(Debug, Error)]
82pub enum EnhanceApplicationError {
83    /// The `IssueStore` returned an error (enqueue, pop, or status
84    /// update).
85    #[error("issue store: {0}")]
86    Issue(#[from] IssueStoreError),
87
88    /// The `EnhanceSettingStore` returned an error while fetching the
89    /// active setting.
90    #[error("setting store: {0}")]
91    Setting(#[from] EnhanceSettingStoreError),
92
93    /// The `BlueprintStore` returned an error while resolving the
94    /// orbit or target Blueprint.
95    #[error("blueprint store: {0}")]
96    Bp(#[from] BlueprintStoreError),
97
98    /// The `EnhanceLogStore` returned an error while appending the
99    /// outcome entry.
100    #[error("enhance log store: {0}")]
101    Log(#[from] EnhanceLogStoreError),
102
103    /// `TaskLaunchService::launch` failed after setup succeeded.
104    #[error("launch: {0}")]
105    Launch(#[from] TaskLaunchError),
106
107    /// Serializing the target Blueprint (or a directive derived from
108    /// it) to JSON/YAML failed.
109    #[error("serialize directive: {0}")]
110    Serialize(#[from] serde_json::Error),
111
112    /// A stored version's `version_label` is not valid semver.
113    #[error("invalid semver version_label {label:?}: {source}")]
114    InvalidSemver {
115        /// The offending label string.
116        label: String,
117        /// The underlying semver parse error.
118        #[source]
119        source: semver::Error,
120    },
121
122    /// No stored version's label satisfies the setting's `SemverReq`.
123    #[error("no version matches semver req: {req}")]
124    NoMatchingVersion {
125        /// The requirement string that matched nothing.
126        req: String,
127    },
128
129    /// The engine reported an error (attach / dispatch).
130    #[error("engine: {0}")]
131    Engine(#[from] EngineError),
132
133    /// `final_ctx.commit` did not match the strict shape
134    /// `extract_commit` expects, or the committer/store hashes
135    /// disagreed.
136    #[error("commit shape: {0}")]
137    CommitShape(String),
138
139    /// The system clock reported a time before the UNIX epoch while
140    /// computing `now_ms`.
141    #[error("system time before UNIX epoch: {0}")]
142    Clock(#[from] std::time::SystemTimeError),
143
144    /// The setting carries an `EnhanceSetting::spawner` override, but the
145    /// orbit Blueprint declares no agent under the name the flow's
146    /// `Step.ref` points at. Fail loud: silently ignoring the override
147    /// would run the Blueprint's own spawner while the operator believes
148    /// the swap took effect.
149    #[error("spawner override: orbit blueprint declares no agent named {name:?}")]
150    SpawnerAgentNotFound {
151        /// The agent name the override targets (= the flow `Step.ref`).
152        name: String,
153    },
154
155    /// The drain stopped waiting on `TaskLaunchService::launch` after
156    /// `EnhanceSetting::ttl_secs` and dropped it. Carries the number it blew
157    /// through so the reason text an operator reads names the knob they can
158    /// raise, not just the fact that something stopped.
159    ///
160    /// Two facts the reason text has to carry, because an operator acts on
161    /// it (see the ceiling block in `dispatch_one` for the evidence):
162    ///
163    /// - Nothing was committed. That one is structural, not best-effort.
164    /// - Only the wait ended. A worker already running in an in-process lane
165    ///   keeps running — this ceiling bounds an Operator wait, and those
166    ///   lanes are not waits. So a re-post can put a second writer under the
167    ///   same `project_root` as the first, which is the one thing an
168    ///   operator must not do by reflex on reading "timed out".
169    #[error(
170        "enhance epoch exceeded the {ttl_secs}s ceiling declared by enhance setting \
171         {setting_id:?} (ttl_secs); nothing was committed and the target Blueprint is \
172         unchanged. Only the wait ended: a worker already running in an in-process lane \
173         is still running, and re-posting now would put a second writer under the same \
174         project_root. Check that it has exited (and what it left there) before \
175         re-posting, and raise ttl_secs if the epoch legitimately needs longer"
176    )]
177    EpochCeilingExceeded {
178        /// The `EnhanceSettingId` whose `ttl_secs` bounded this epoch.
179        setting_id: String,
180        /// The ceiling in seconds, as declared by the setting.
181        ttl_secs: u64,
182    },
183
184    /// `EnhanceSetting::ttl_secs` is `0`.
185    ///
186    /// Zero is refused rather than read as "no ceiling". This repo has no
187    /// zero-means-unbounded TTL: `mse serve` refuses
188    /// `worker_token_ttl_secs: 0` at startup and `POST /v1/tasks` rejects
189    /// `timeout_secs: 0` with a `400`, both on the same ground — a zero TTL
190    /// is a typo, not a policy. Inventing the sentinel here would recreate
191    /// the exact defect this ceiling exists to remove: a field whose value
192    /// does not mean what the field says it means.
193    ///
194    /// The guard belongs one layer out, at `POST /v1/enhance-settings`,
195    /// where it could be a `400` at write time instead of a rejected issue
196    /// at dispatch time. It lives here because dispatch is the last place
197    /// that can still refuse to run an epoch it would abort on its first
198    /// poll.
199    #[error(
200        "enhance setting {setting_id:?} declares ttl_secs: 0, which would abort every epoch \
201         before its first step completes; set ttl_secs to the number of seconds one epoch \
202         may run"
203    )]
204    ZeroTtl {
205        /// The `EnhanceSettingId` carrying the zero.
206        setting_id: String,
207    },
208}
209
210impl From<SemverResolveError> for EnhanceApplicationError {
211    fn from(e: SemverResolveError) -> Self {
212        match e {
213            SemverResolveError::Store(e) => EnhanceApplicationError::Bp(e),
214            SemverResolveError::InvalidSemver { label, source } => {
215                EnhanceApplicationError::InvalidSemver { label, source }
216            }
217            SemverResolveError::NoMatchingVersion { req } => {
218                EnhanceApplicationError::NoMatchingVersion { req }
219            }
220        }
221    }
222}
223
224/// Result of a single `tick`. `task_id` is gone — the flow-eval path
225/// runs many steps to completion instead of being tied to a single
226/// task id, so the entire `final_ctx` is the result. Outcomes are
227/// checked through `status`.
228#[derive(Debug, Clone)]
229pub struct TickOutcome {
230    /// The issue that was popped and dispatched this tick.
231    pub issue_id: IssueId,
232    /// The resulting status persisted to the `IssueStore`.
233    pub status: IssueStatus,
234}
235
236/// Configuration parameters for `EnhanceApplication`.
237///
238/// `ttl` moved onto `EnhanceSetting` so editing the setting acts as
239/// a hot reload — including the epoch ceiling, which is re-read from the
240/// setting on every tick rather than frozen at construction. This
241/// `Config` only holds the identity information needed to stand up an
242/// Application instance.
243pub struct EnhanceApplicationConfig {
244    /// A short identifier for this Application instance (used in logs).
245    pub name: String,
246    /// The `EnhanceSetting` this instance reads on every tick.
247    pub setting_id: EnhanceSettingId,
248    /// The Operator id attached for every dispatched task.
249    pub operator_id: String,
250    /// The Operator's role for every dispatched task.
251    pub role: Role,
252}
253
254/// The `POST /v1/issues` dispatcher — enqueues via [`Application::handle`],
255/// drains via [`EnhanceApplication::tick`] / [`EnhanceApplication::run_forever`].
256pub struct EnhanceApplication {
257    name: String,
258    setting_id: EnhanceSettingId,
259    operator_id: String,
260    role: Role,
261    issue_store: Arc<dyn IssueStore>,
262    setting_store: Arc<dyn EnhanceSettingStore>,
263    bp_store: Arc<dyn BlueprintStore>,
264    log_store: Arc<dyn EnhanceLogStore>,
265    launch: Arc<TaskLaunchService>,
266}
267
268impl EnhanceApplication {
269    /// Wire up an `EnhanceApplication` from its config and store/service
270    /// dependencies.
271    pub fn new(
272        cfg: EnhanceApplicationConfig,
273        issue_store: Arc<dyn IssueStore>,
274        setting_store: Arc<dyn EnhanceSettingStore>,
275        bp_store: Arc<dyn BlueprintStore>,
276        log_store: Arc<dyn EnhanceLogStore>,
277        launch: Arc<TaskLaunchService>,
278    ) -> Self {
279        Self {
280            name: cfg.name,
281            setting_id: cfg.setting_id,
282            operator_id: cfg.operator_id,
283            role: cfg.role,
284            issue_store,
285            setting_store,
286            bp_store,
287            log_store,
288            launch,
289        }
290    }
291
292    /// The `IssueStore` this Application enqueues into and drains from.
293    pub fn issue_store(&self) -> &Arc<dyn IssueStore> {
294        &self.issue_store
295    }
296
297    /// The `BlueprintStore` used to resolve orbit/target Blueprints and
298    /// to persist Applied commits.
299    pub fn bp_store(&self) -> &Arc<dyn BlueprintStore> {
300        &self.bp_store
301    }
302
303    /// The `EnhanceLogStore` every dispatch outcome is appended to.
304    pub fn log_store(&self) -> &Arc<dyn EnhanceLogStore> {
305        &self.log_store
306    }
307
308    /// Pop one pending issue and dispatch it to the engine. Returns
309    /// `None` when nothing is pending.
310    ///
311    /// `dispatch_one` returns `Err` only for **infra faults** — store,
312    /// launch, clock, shape errors, and the like. Flow verifier denials
313    /// come back through `dispatch_one` on the `Ok` path with a
314    /// `Rejected` status, and the corresponding entry has already been
315    /// appended to `log_store` in the same commit. Even on an infra
316    /// fault, `tick` best-effort tries to update the store-side
317    /// status; if the store itself is broken the error propagates.
318    pub async fn tick(&self) -> Result<Option<TickOutcome>, EnhanceApplicationError> {
319        let Some(payload) = self.issue_store.pop_pending().await? else {
320            return Ok(None);
321        };
322        match self.dispatch_one(&payload).await {
323            Ok(status) => {
324                self.issue_store
325                    .update_status(&payload.issue_id, status.clone())
326                    .await?;
327                Ok(Some(TickOutcome {
328                    issue_id: payload.issue_id,
329                    status,
330                }))
331            }
332            Err(e) => {
333                // Infra fault: record status as Rejected, then propagate Err.
334                let reason = format!("dispatch failed: {e}");
335                self.issue_store
336                    .update_status(&payload.issue_id, IssueStatus::Rejected { reason })
337                    .await?;
338                Err(e)
339            }
340        }
341    }
342
343    /// Handle one issue as one enhance-flow completion.
344    ///
345    /// Flow:
346    /// 1. Fetch the setting (the enhance-orbit BP id, `verifier_axes`,
347    ///    and `ttl_secs`).
348    /// 2. Resolve the orbit BP (for example the built-in
349    ///    `enhance-default` flow), then apply the setting's
350    ///    `spawner` override to it when one is declared.
351    /// 3. Resolve the target BP (`payload.blueprint_id`) — the
352    ///    object being modified, injected into `init_ctx` as
353    ///    `prev_bp`.
354    /// 4. Assemble `init_ctx` (`issue` / `prev_bp_yaml` / `prev_hash`
355    ///    / `epoch_id` / `verifiers`).
356    /// 5. Run to completion via `TaskLaunchService::launch`, waiting at
357    ///    most `ttl_secs` for it — pull `final_ctx` once every step
358    ///    finishes, or give up waiting with
359    ///    [`EnhanceApplicationError::EpochCeilingExceeded`]. Giving up does
360    ///    not stop the flow; see the ceiling block below.
361    /// 6. Derive `IssueStatus` from `final_ctx.commit`; when Applied,
362    ///    persist via `bp_store.write_new`.
363    /// 7. Append a `LogEntry` to `log_store` — exactly one entry per
364    ///    outcome, Applied or Rejected.
365    async fn dispatch_one(
366        &self,
367        payload: &IssuePayload,
368    ) -> Result<IssueStatus, EnhanceApplicationError> {
369        let setting = self.setting_store.get(&self.setting_id).await?;
370
371        // Refuse a zero ceiling before touching a store. Every step below
372        // this point costs work an epoch with a 0s ceiling could only throw
373        // away on its first poll, and a `ZeroTtl` reason names the setting
374        // field to fix where an `EpochCeilingExceeded` reason would just
375        // report a stopwatch. See that variant's doc for why `0` is not
376        // read as "unbounded".
377        if setting.ttl_secs == 0 {
378            return Err(EnhanceApplicationError::ZeroTtl {
379                setting_id: self.setting_id.to_string(),
380            });
381        }
382
383        let mut traced_orch = self
384            .resolve_blueprint(&setting.blueprint_id, &setting.version)
385            .await?;
386        apply_spawner_override(&mut traced_orch.value, setting.spawner.as_ref())?;
387
388        let traced_target = self.bp_store.read_head(&payload.blueprint_id).await?;
389        let prev_bp_yaml = serde_yaml::to_string(&traced_target.value).map_err(|e| {
390            EnhanceApplicationError::Serialize(serde::ser::Error::custom(format!(
391                "prev_bp yaml: {e}"
392            )))
393        })?;
394        let prev_version = blueprint_version(&traced_target.value).map_err(|e| {
395            EnhanceApplicationError::Serialize(serde::ser::Error::custom(format!("prev_hash: {e}")))
396        })?;
397        let now_ms = std::time::SystemTime::now()
398            .duration_since(std::time::UNIX_EPOCH)?
399            .as_millis() as i64;
400        let epoch = BlueprintEpoch::new(payload.blueprint_id.clone(), prev_version, now_ms);
401        let prev_hash_hex = hex::encode(prev_version.0 .0);
402
403        let init_ctx = serde_json::json!({
404            "issue": {
405                "issue_id":     payload.issue_id.as_str(),
406                "blueprint_id": payload.blueprint_id.as_str(),
407                "intent":       payload.intent,
408            },
409            "prev_bp_yaml": prev_bp_yaml,
410            "prev_hash":    prev_hash_hex.clone(),
411            "epoch_id":     epoch.clone(),
412            "verifiers":    setting.verifier_axes.clone(),
413        });
414
415        // ── The epoch ceiling ────────────────────────────────────────────
416        //
417        // `ttl` is handed to the launch twice, and only one of the two does
418        // anything.
419        //
420        // Inside `launch` it is stamped onto the minted `Role::Operator`
421        // token's `expire_at`, where `82d9da9` ("stop expiring operator
422        // tokens") made `Engine::verify_token` skip the expiry check for
423        // that role. That exemption is correct and is not being walked
424        // back: the operator session token never leaves the process, so its
425        // TTL bounded no reachable capability — all it did was fail the
426        // *next* legitimate `start_task` once a single step outlived the
427        // attach. What the exemption left behind is the reason this block
428        // exists: a number the author sets, still accepted, still
429        // serialized, gating nothing.
430        //
431        // Around `launch` it bounds how long this dispatcher waits for the
432        // flow to come back. The `/v1/tasks` detached path has carried the
433        // same `tokio::time::timeout` wrap around its driver since GH #37
434        // (`run_flow_form`, the `if detach` arm); the enhance consumer loop
435        // had nothing equivalent, so a `patch-spawner` whose model call hung
436        // wedged `tick()` forever and with it the whole single-threaded drain
437        // — every later issue stuck `pending` behind one stalled epoch.
438        // Reusing that mechanism rather than inventing a second one keeps one
439        // answer to "what unwedges the drain" in the codebase.
440        //
441        // Be precise about what that buys, because the obvious reading is
442        // wrong twice over.
443        //
444        // (1) What it bounds is waiting — which is what it was built to
445        // bound. This TTL exists so the Swarm can give up on an Operator
446        // that has stopped answering; model §4.4 **R5** puts the bound here
447        // deliberately ("model は待ち時間の上限を規定しない — 上限は infra が
448        // 持ち config で伸ばす"). That wait is an await point:
449        // `WSOperatorSession::send_and_await` parks on `orx.await`
450        // (`crates/mlua-swarm-server/src/operator_ws/session.rs`), so
451        // dropping this future releases it where it stands. For the lane
452        // this knob names, the ceiling does exactly what it says.
453        //
454        // It does not reach work that is not a wait, and is not meant to.
455        // `tokio::time::timeout` polls the inner future first and returns
456        // `Ok` the moment it is `Ready`, deadline or not, so a step that
457        // never awaits cannot be interrupted by it at all; and the
458        // in-process lanes (`InProcSpawner`, the agent-block orbit, the
459        // subprocess backend) run their work in a `tokio::spawn`ed task
460        // that is not a child of this future, so dropping the wait does not
461        // reach them either. Read that as a property of those lanes rather
462        // than a hole in the ceiling: a computation running in this process
463        // is not an unresponsive Operator, which is the thing this TTL was
464        // put here to see through.
465        //
466        // (2) It covers `launch` and nothing else. Outside it, and bounded
467        // by nothing: `setting_store.get`, `resolve_blueprint`, and
468        // `bp_store.read_head` before; `bp_store.write_new` and
469        // `log_store.append` after; `issue_store.update_status` in both of
470        // `tick`'s arms. A `BlueprintStore` blocking on git index-lock
471        // contention therefore wedges the drain exactly as a hung spawner
472        // used to.
473        //
474        // Widening the wrap to span the epoch was considered and rejected on
475        // two counts. It would not bound those store calls:
476        // `Git2BlueprintStore` (`src/blueprint/store/git2_store.rs`) contains
477        // no `.await` and no `spawn_blocking` in the whole file, so its
478        // `async fn`s do their git2 work inline and return `Ready` on the
479        // first poll — by (1), a wrap around them can never fire, which
480        // would add a second knob that does nothing and recreate the exact
481        // defect this ceiling exists to remove. And a wrap that *could* fire
482        // around `write_new` would let the ceiling land mid-commit,
483        // destroying the one guarantee below that is real. The honest fix
484        // for the store calls is to make them cancel-safe (`spawn_blocking`)
485        // first; until then this is a KNOWN LIMITATION, stated rather than
486        // papered over.
487        //
488        // Two alternatives were rejected. Deprecating the field and warning
489        // on use (the cheap fix) removes the lie but leaves the enhance
490        // orbit unbounded, so it trades a misleading knob for a missing
491        // one. Reaping expired operator sessions on a timer would restore
492        // the TTL's *original* meaning, but that is precisely the misfire
493        // `82d9da9` deleted — killing a session out from under a step that
494        // is merely slow — and would reintroduce it by the back door.
495        //
496        // STORE STATE after a fired ceiling: nothing partial. This epoch's
497        // only write to the `BlueprintStore` is the single `write_new` in
498        // the Applied arm below, strictly after `launch` returns, so a
499        // ceiling that fires can only fire before it — the target
500        // Blueprint's head is byte-identical to what it was when the issue
501        // was popped, and there is no half-written version for a later epoch
502        // to reconcile against. The abort is reported as an infra fault, so
503        // `tick`'s `Err` arm marks the issue terminal `Rejected` with the
504        // reason text above and nothing is appended to `log_store` —
505        // consistent with every other infra fault, and it keeps the log's
506        // invariant that an entry carries per-axis verdicts (a timed-out
507        // epoch has none to carry). Recovery is re-posting the issue, not
508        // resuming: this launch passes `run_ctx: None`, so no `RunRecord`
509        // and no replay log exist for `POST /v1/runs/:id/resume` to address.
510        // A re-post starts from the same `prev_hash` precisely because
511        // nothing was committed.
512        //
513        // That claim is about stores. Execution is a separate claim, and a
514        // weaker one.
515        //
516        // EXECUTION after a fired ceiling: the wait ends, the work may not.
517        // The Operator lane's wait is released, per (1). A worker already
518        // running inside one of the in-process lanes is not: `InProcSpawner`
519        // (`src/worker/adapter.rs`), the agent-block orbit
520        // (`src/worker/agent_block/runtime.rs`) and the subprocess backend
521        // (`src/worker/process_spawner.rs`) each run their work in a
522        // `tokio::spawn`ed task, and each selects on a `CancellationToken`
523        // that **nothing in this repository fires** — `Engine::cancel_task`
524        // (`src/core/engine.rs`) sets `TaskStatus::Cancelled` and wakes
525        // pollers without touching it. So such a worker runs to its own end,
526        // and its `submit_output` lands against an epoch nobody is reading
527        // (inert here: `TaskLaunchInput::automate` sets `task_input: None`,
528        // so the file-materialize half writes nothing).
529        //
530        // The consequence an operator has to act on: **a re-post can overlap
531        // the previous epoch's worker**, with both writing under the same
532        // `project_root`. Check that the earlier one has exited before
533        // re-posting, or raise `ttl_secs` so the epoch is not cut short to
534        // begin with. `mse://guides/enhance-flow` carries this as the
535        // recovery procedure.
536        //
537        // Making the ceiling reach those lanes would mean giving this
538        // process a drop-cancels-work semantics, which is a much larger
539        // change than a TTL on an Operator wait and is not what this knob
540        // asked for. It is recorded here as the boundary, not as a to-do.
541        //
542        // Mutations that DO survive the abort, named so the paragraph above
543        // is not read as "and nothing else": `engine.register_verdict_contracts`
544        // (`src/service/task_launch.rs`) persists, and the operator session
545        // attached during launch is never detached, because the token lives
546        // in the `TaskLaunchOutput` of the future being dropped. That
547        // session leak is pre-existing and shared with the success path,
548        // which never detaches either (`token: _` below).
549        let ttl = Duration::from_secs(setting.ttl_secs);
550        let launch = self.launch.launch(TaskLaunchInput::automate(
551            traced_orch.value,
552            self.operator_id.clone(),
553            self.role,
554            ttl,
555            init_ctx,
556        ));
557        let TaskLaunchOutput {
558            token: _,
559            final_ctx,
560        } = match tokio::time::timeout(ttl, launch).await {
561            Ok(launched) => launched?,
562            Err(_elapsed) => {
563                tracing::warn!(
564                    issue_id = %payload.issue_id,
565                    blueprint_id = %payload.blueprint_id,
566                    setting_id = %self.setting_id,
567                    ttl_secs = setting.ttl_secs,
568                    prev_hash = %prev_hash_hex,
569                    "enhance epoch hit its ttl_secs ceiling; this dispatcher stopped waiting \
570                     and nothing was committed. A worker already running in an in-process \
571                     lane is NOT stopped by this — it runs to its own end, so check that it \
572                     has exited (and what it left under project_root) before re-posting, or \
573                     raise ttl_secs"
574                );
575                return Err(EnhanceApplicationError::EpochCeilingExceeded {
576                    setting_id: self.setting_id.to_string(),
577                    ttl_secs: setting.ttl_secs,
578                });
579            }
580        };
581
582        // Strict commit extract (no 1-value default; missing required fields surface as Err).
583        let commit_decision = extract_commit(&final_ctx)?;
584
585        // When Applied, persist via bp_store.write_new (the core GOAL IO path).
586        let (status, log_entry) = match commit_decision {
587            CommitDecision::Applied {
588                new_bp,
589                new_version_hex,
590                rationale,
591                bump,
592                verdicts,
593            } => {
594                let patch_hash = ContentHash::from_bytes(rationale.as_bytes());
595                let metadata = CommitMetadata {
596                    epoch_id: epoch.clone(),
597                    rationale: rationale.clone(),
598                    patch_hash,
599                };
600                let new_version = self
601                    .bp_store
602                    .write_new(
603                        &payload.blueprint_id,
604                        &new_bp,
605                        std::slice::from_ref(&prev_version),
606                        metadata,
607                    )
608                    .await?;
609                let new_version_hex_actual = hex::encode(new_version.0 .0);
610                // If commit.new_version (the committer-computed hash) disagrees with the
611                // version assigned by bp_store, the canonicalisation is out of sync — Err.
612                if new_version_hex_actual != new_version_hex {
613                    return Err(EnhanceApplicationError::CommitShape(format!(
614                        "new_version mismatch: committer={new_version_hex} store={new_version_hex_actual}"
615                    )));
616                }
617                let entry = EnhanceLogEntry {
618                    issue_id: payload.issue_id.clone(),
619                    blueprint_id: payload.blueprint_id.clone(),
620                    prev_hash: prev_hash_hex.clone(),
621                    new_hash: new_version_hex_actual.clone(),
622                    intent: payload.intent.clone(),
623                    rationale: rationale.clone(),
624                    verdicts,
625                    status: "applied".into(),
626                    reasons: vec![],
627                    ts_ms: now_ms,
628                };
629                // CommitMetadata does not carry the bump label; surface it in
630                // the trace so the committer's version decision is observable.
631                tracing::info!(%bump, issue_id = %payload.issue_id, "commit bump label (not persisted in CommitMetadata)");
632                (
633                    IssueStatus::Applied {
634                        new_version: new_version_hex_actual,
635                    },
636                    entry,
637                )
638            }
639            CommitDecision::Rejected {
640                reasons,
641                rationale,
642                verdicts,
643            } => {
644                let entry = EnhanceLogEntry {
645                    issue_id: payload.issue_id.clone(),
646                    blueprint_id: payload.blueprint_id.clone(),
647                    prev_hash: prev_hash_hex.clone(),
648                    new_hash: String::new(),
649                    intent: payload.intent.clone(),
650                    rationale,
651                    verdicts,
652                    status: "rejected".into(),
653                    reasons: reasons.clone(),
654                    ts_ms: now_ms,
655                };
656                (
657                    IssueStatus::Rejected {
658                        reason: format!("verifier deny: {}", reasons.join("; ")),
659                    },
660                    entry,
661                )
662            }
663        };
664
665        self.log_store.append(log_entry).await?;
666        Ok(status)
667    }
668
669    /// Resolve a BP per the `VersionSelector`. `Latest` uses
670    /// `read_head`; `Fixed` uses `read_version`; `SemverReq` scans the
671    /// history and picks the semver-matching
672    /// `BlueprintMetadata.version_label`.
673    async fn resolve_blueprint(
674        &self,
675        bp_id: &BlueprintId,
676        selector: &VersionSelector,
677    ) -> Result<Traced<Blueprint>, EnhanceApplicationError> {
678        match selector {
679            VersionSelector::Latest => Ok(self.bp_store.read_head(bp_id).await?),
680            VersionSelector::Fixed { value } => {
681                Ok(self.bp_store.read_version(bp_id, *value).await?)
682            }
683            VersionSelector::SemverReq { req } => {
684                let v = super::semver_resolve::resolve_semver(self.bp_store.as_ref(), bp_id, req)
685                    .await?;
686                Ok(self.bp_store.read_version(bp_id, v).await?)
687            }
688        }
689    }
690
691    /// The consumer loop. At server startup, launch it with
692    /// `tokio::spawn(app.run_forever(interval))`; stop it with
693    /// `JoinHandle::abort()`.
694    ///
695    /// Behaviour:
696    ///
697    /// - `tick()` returns `Some` → immediately run another tick (burst
698    ///   drain).
699    /// - `tick()` returns `None` → sleep for `interval` (no-work
700    ///   back-off).
701    /// - `tick()` returns `Err` → log it and sleep for `interval`
702    ///   (a dispatch failure must not kill the loop).
703    pub async fn run_forever(self: Arc<Self>, interval: Duration) {
704        loop {
705            match self.tick().await {
706                Ok(Some(_)) => continue,
707                Ok(None) => tokio::time::sleep(interval).await,
708                Err(e) => {
709                    eprintln!("[{}] tick error: {e}", self.name);
710                    tokio::time::sleep(interval).await;
711                }
712            }
713        }
714    }
715}
716
717/// Input to [`EnhanceApplication::handle`] — the `POST /v1/issues` request
718/// body once decoded.
719#[derive(Debug, Clone)]
720pub struct EnhanceApplicationInput {
721    /// The Blueprint this issue proposes to modify.
722    pub blueprint_id: BlueprintId,
723    /// Free-form description of the change being requested.
724    pub intent: String,
725    /// Caller-supplied issue id, echoed back as `handle`'s `Output`.
726    pub issue_id: IssueId,
727}
728
729/// Swap the orbit Blueprint's [`AG_PATCH_SPAWNER`] agent for the
730/// definition the setting declares.
731///
732/// `spawner = None` leaves the Blueprint untouched — whatever it
733/// declares is what runs (the pre-override behaviour, byte-for-byte).
734/// `Some(def)` replaces the matching entry in `blueprint.agents` in
735/// place, which is the whole point of the knob: the spawner's execution
736/// backend can be changed without rewriting the Blueprint.
737///
738/// Two deliberate strictnesses:
739///
740/// - No agent under that name → `Err`. The flow step references the
741///   agent by name, so a missing target means the override is inert; a
742///   silent no-op here is the worst possible way for that to surface.
743/// - The swapped-in `name` is forced back to [`AG_PATCH_SPAWNER`], so a
744///   caller supplying a differently-named `AgentDef` cannot break the
745///   flow's `Step.ref` wiring.
746///
747/// The mutation is scoped to the in-memory copy used for this dispatch —
748/// nothing is written back to the `BlueprintStore`.
749fn apply_spawner_override(
750    blueprint: &mut Blueprint,
751    spawner: Option<&AgentDef>,
752) -> Result<(), EnhanceApplicationError> {
753    let Some(spawner) = spawner else {
754        return Ok(());
755    };
756    let slot = blueprint
757        .agents
758        .iter_mut()
759        .find(|a| a.name == AG_PATCH_SPAWNER)
760        .ok_or_else(|| EnhanceApplicationError::SpawnerAgentNotFound {
761            name: AG_PATCH_SPAWNER.to_string(),
762        })?;
763    let mut swapped = spawner.clone();
764    swapped.name = AG_PATCH_SPAWNER.to_string();
765    *slot = swapped;
766    Ok(())
767}
768
769/// Internal verdict produced by strictly parsing `committer.lua`'s
770/// output (`ctx.commit`).
771///
772/// Strict discipline: missing required fields or wrong types surface
773/// as `CommitShape` errors — no 1-value defaulting.
774enum CommitDecision {
775    Applied {
776        new_bp: Box<Blueprint>,
777        new_version_hex: String,
778        rationale: String,
779        bump: String,
780        verdicts: Vec<VerdictSummary>,
781    },
782    Rejected {
783        reasons: Vec<String>,
784        rationale: String,
785        verdicts: Vec<VerdictSummary>,
786    },
787}
788
789fn extract_commit(
790    final_ctx: &serde_json::Value,
791) -> Result<CommitDecision, EnhanceApplicationError> {
792    let shape_err =
793        |msg: String| -> EnhanceApplicationError { EnhanceApplicationError::CommitShape(msg) };
794
795    let commit = final_ctx
796        .get("commit")
797        .ok_or_else(|| shape_err("final_ctx missing $.commit".into()))?;
798    let committed = commit
799        .get("committed")
800        .and_then(|v| v.as_bool())
801        .ok_or_else(|| shape_err("commit.committed missing or not bool".into()))?;
802    let rationale = commit
803        .get("rationale")
804        .and_then(|v| v.as_str())
805        .ok_or_else(|| shape_err("commit.rationale missing or not string".into()))?
806        .to_string();
807    let verdicts = parse_verdicts_summary(commit)?;
808
809    if committed {
810        let new_version_hex = commit
811            .get("new_version")
812            .and_then(|v| v.as_str())
813            .ok_or_else(|| shape_err("commit.new_version missing or not string".into()))?
814            .to_string();
815        if new_version_hex.is_empty() {
816            return Err(shape_err("commit.new_version is empty (Applied)".into()));
817        }
818        let bump = commit
819            .get("bump")
820            .and_then(|v| v.as_str())
821            .ok_or_else(|| shape_err("commit.bump missing or not string".into()))?
822            .to_string();
823        let new_bp_json = commit
824            .get("new_bp_json")
825            .ok_or_else(|| shape_err("commit.new_bp_json missing".into()))?
826            .clone();
827        let new_bp: Box<Blueprint> = serde_json::from_value(new_bp_json)
828            .map_err(|e| shape_err(format!("commit.new_bp_json deserialize: {e}")))?;
829        Ok(CommitDecision::Applied {
830            new_bp,
831            new_version_hex,
832            rationale,
833            bump,
834            verdicts,
835        })
836    } else {
837        let reasons_arr = commit
838            .get("reasons")
839            .and_then(|v| v.as_array())
840            .ok_or_else(|| shape_err("commit.reasons missing or not array".into()))?;
841        let reasons: Vec<String> = reasons_arr
842            .iter()
843            .map(|v| {
844                v.as_str()
845                    .map(|s| s.to_string())
846                    .ok_or_else(|| shape_err("commit.reasons[] contains non-string element".into()))
847            })
848            .collect::<Result<_, _>>()?;
849        if reasons.is_empty() {
850            return Err(shape_err(
851                "commit.reasons is empty (Rejected requires at least 1)".into(),
852            ));
853        }
854        Ok(CommitDecision::Rejected {
855            reasons,
856            rationale,
857            verdicts,
858        })
859    }
860}
861
862fn parse_verdicts_summary(
863    commit: &serde_json::Value,
864) -> Result<Vec<VerdictSummary>, EnhanceApplicationError> {
865    let arr = commit
866        .get("verdicts_summary")
867        .and_then(|v| v.as_array())
868        .ok_or_else(|| {
869            EnhanceApplicationError::CommitShape(
870                "commit.verdicts_summary missing or not array".into(),
871            )
872        })?;
873    arr.iter()
874        .map(|v| {
875            let axis = v
876                .get("axis")
877                .and_then(|x| x.as_str())
878                .ok_or_else(|| {
879                    EnhanceApplicationError::CommitShape("verdicts_summary[].axis missing".into())
880                })?
881                .to_string();
882            let status = v
883                .get("status")
884                .and_then(|x| x.as_str())
885                .ok_or_else(|| {
886                    EnhanceApplicationError::CommitShape("verdicts_summary[].status missing".into())
887                })?
888                .to_string();
889            let detail = match status.as_str() {
890                "pass" => v
891                    .get("evidence")
892                    .and_then(|x| x.as_str())
893                    .ok_or_else(|| {
894                        EnhanceApplicationError::CommitShape(
895                            "verdicts_summary[].evidence missing for pass".into(),
896                        )
897                    })?
898                    .to_string(),
899                "deny" => v
900                    .get("reason")
901                    .and_then(|x| x.as_str())
902                    .ok_or_else(|| {
903                        EnhanceApplicationError::CommitShape(
904                            "verdicts_summary[].reason missing for deny".into(),
905                        )
906                    })?
907                    .to_string(),
908                other => {
909                    return Err(EnhanceApplicationError::CommitShape(format!(
910                        "verdicts_summary[].status must be pass|deny, got {other}"
911                    )))
912                }
913            };
914            Ok(VerdictSummary {
915                axis,
916                status,
917                detail,
918            })
919        })
920        .collect()
921}
922
923#[async_trait]
924impl Application for EnhanceApplication {
925    type Input = EnhanceApplicationInput;
926    type Output = IssueId;
927    type Error = EnhanceApplicationError;
928
929    fn name(&self) -> &str {
930        &self.name
931    }
932
933    /// Just push the issue onto `IssueStore` — a synchronous enqueue;
934    /// dispatch is entirely the consumer loop's job.
935    async fn handle(&self, input: Self::Input) -> Result<Self::Output, Self::Error> {
936        self.issue_store
937            .create(IssuePayload {
938                issue_id: input.issue_id.clone(),
939                blueprint_id: input.blueprint_id,
940                intent: input.intent,
941            })
942            .await?;
943        Ok(input.issue_id)
944    }
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950    use crate::blueprint::AgentKind;
951    use crate::enhance::blueprint::default_blueprint;
952
953    fn spawner_of(bp: &Blueprint) -> &AgentDef {
954        bp.agents
955            .iter()
956            .find(|a| a.name == AG_PATCH_SPAWNER)
957            .expect("blueprint declares a patch-spawner agent")
958    }
959
960    fn subprocess_spawner(name: &str) -> AgentDef {
961        serde_json::from_value(serde_json::json!({
962            "name": name,
963            "kind": "subprocess",
964            "spec": { "program": "true", "args": [] },
965        }))
966        .expect("literal is a valid AgentDef")
967    }
968
969    #[test]
970    fn no_override_keeps_the_blueprints_own_spawner() {
971        let mut bp = default_blueprint();
972        let before = spawner_of(&bp).clone();
973        apply_spawner_override(&mut bp, None).unwrap();
974        assert_eq!(spawner_of(&bp), &before);
975        assert_eq!(spawner_of(&bp).kind, AgentKind::AgentBlock);
976    }
977
978    #[test]
979    fn override_swaps_the_spawner_and_forces_the_referenced_name() {
980        let mut bp = default_blueprint();
981        let agents_before = bp.agents.len();
982        // A deliberately mis-named override: the flow references the
983        // agent by name, so the swap must rename it back.
984        let def = subprocess_spawner("my-own-spawner");
985        apply_spawner_override(&mut bp, Some(&def)).unwrap();
986
987        let swapped = spawner_of(&bp);
988        assert_eq!(swapped.kind, AgentKind::Subprocess);
989        assert_eq!(swapped.name, AG_PATCH_SPAWNER);
990        assert_eq!(swapped.spec, def.spec);
991        // A swap, not an insert — the other three agents are untouched.
992        assert_eq!(bp.agents.len(), agents_before);
993        assert!(!bp.agents.iter().any(|a| a.name == "my-own-spawner"));
994    }
995
996    // ─── UT: the `EnhanceSetting.ttl_secs` ceiling ────────────────────
997    //
998    // The ceiling is the only thing that gives `ttl_secs` an effect since
999    // `82d9da9` exempted Operator tokens from expiry, so these tests pin
1000    // three things: an overrun stops the wait with nothing committed, a run
1001    // inside the ceiling still commits exactly as before, and — the one
1002    // that keeps the docs honest — the worker stops with it. Losing the
1003    // first is a regression back to an inert knob and an unbounded drain;
1004    // losing the second is a ceiling that fires on healthy runs; losing the
1005    // third puts a live worker back on the tree an operator is about to
1006    // re-post against, and makes the module doc, the `EpochCeilingExceeded`
1007    // text and `mse://guides/enhance-flow` describe behaviour the code no
1008    // longer has.
1009
1010    use crate::blueprint::compiler::{Compiler, RustFnInProcessSpawnerFactory, SpawnerRegistry};
1011    use crate::blueprint::store::{BlueprintId, CommitMetadata, InMemoryBlueprintStore};
1012    use crate::core::config::EngineCfg;
1013    use crate::core::engine::Engine;
1014    use crate::enhance::setting::{EnhanceSetting, EnhanceSettingMeta};
1015    use crate::store::enhance_log::InMemoryEnhanceLogStore;
1016    use crate::store::enhance_setting::InMemoryEnhanceSettingStore;
1017    use crate::store::issue::InMemoryIssueStore;
1018    use crate::worker::adapter::WorkerResult;
1019    use mlua_flow_ir::{Expr, Node as FlowNode};
1020    use serde_json::json;
1021
1022    const TARGET_BP: &str = "target-ut";
1023
1024    /// A one-step orbit Blueprint whose `patch-spawner` is the `RustFn`
1025    /// registered under `fn_id`, writing its result to `$.commit` — the
1026    /// key `extract_commit` reads. Everything else is inherited from the
1027    /// real `default_blueprint()` so the compile path under test is the
1028    /// production one.
1029    fn orbit_bp(fn_id: &str) -> Blueprint {
1030        let mut bp = default_blueprint();
1031        bp.id = "orbit-ut".into();
1032        bp.flow = FlowNode::Step {
1033            ref_: AG_PATCH_SPAWNER.into(),
1034            in_: Expr::Lit {
1035                value: serde_json::Value::Null,
1036            },
1037            out: Expr::Path {
1038                at: "$.commit".parse().expect("literal test path: $.commit"),
1039            },
1040        };
1041        bp.agents = vec![serde_json::from_value(json!({
1042            "name": AG_PATCH_SPAWNER,
1043            "kind": "rust_fn",
1044            "spec": { "fn_id": fn_id },
1045        }))
1046        .expect("literal is a valid AgentDef")];
1047        bp
1048    }
1049
1050    /// Everything an `EnhanceApplication` needs, kept alongside it so a
1051    /// test can assert on what the stores hold after a tick.
1052    struct Harness {
1053        app: EnhanceApplication,
1054        issues: Arc<InMemoryIssueStore>,
1055        bps: Arc<InMemoryBlueprintStore>,
1056        logs: Arc<InMemoryEnhanceLogStore>,
1057        target_id: BlueprintId,
1058    }
1059
1060    async fn harness(
1061        fn_id: &str,
1062        factory: RustFnInProcessSpawnerFactory,
1063        ttl_secs: u64,
1064    ) -> Harness {
1065        let mut registry = SpawnerRegistry::new();
1066        registry.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1067        let launch =
1068            TaskLaunchService::new(Engine::new(EngineCfg::default()), Compiler::new(registry));
1069
1070        // Seed the target Blueprint — `dispatch_one` reads its head to
1071        // build `prev_bp_yaml` / `prev_hash`.
1072        let bps = Arc::new(InMemoryBlueprintStore::new());
1073        let target_id = BlueprintId::new(TARGET_BP.to_string());
1074        let target = default_blueprint();
1075        let seed_version =
1076            crate::blueprint::store::blueprint_version(&target).expect("seed hashes");
1077        bps.write_new(
1078            &target_id,
1079            &target,
1080            &[],
1081            CommitMetadata::seed(target_id.clone(), seed_version, 0),
1082        )
1083        .await
1084        .expect("seed the target Blueprint");
1085
1086        let settings = Arc::new(InMemoryEnhanceSettingStore::new());
1087        let setting_id = EnhanceSettingId::default_id();
1088        settings
1089            .put(
1090                &setting_id,
1091                EnhanceSetting {
1092                    id: setting_id.to_string(),
1093                    blueprint_id: BlueprintId::new("orbit-ut".to_string()),
1094                    ttl_secs,
1095                    version: crate::application::VersionSelector::default(),
1096                    verifier_axes: vec![],
1097                    spawner: None,
1098                    meta: EnhanceSettingMeta::default(),
1099                },
1100            )
1101            .await
1102            .expect("put the setting");
1103
1104        // The orbit Blueprint is resolved out of the same store.
1105        let orbit = orbit_bp(fn_id);
1106        let orbit_id = BlueprintId::new("orbit-ut".to_string());
1107        let orbit_version =
1108            crate::blueprint::store::blueprint_version(&orbit).expect("orbit hashes");
1109        bps.write_new(
1110            &orbit_id,
1111            &orbit,
1112            &[],
1113            CommitMetadata::seed(orbit_id.clone(), orbit_version, 0),
1114        )
1115        .await
1116        .expect("seed the orbit Blueprint");
1117
1118        let issues = Arc::new(InMemoryIssueStore::new());
1119        let logs = Arc::new(InMemoryEnhanceLogStore::new());
1120        let app = EnhanceApplication::new(
1121            EnhanceApplicationConfig {
1122                name: "ut".into(),
1123                setting_id,
1124                operator_id: "ut-op".into(),
1125                role: Role::Operator,
1126            },
1127            issues.clone(),
1128            settings,
1129            bps.clone(),
1130            logs.clone(),
1131            Arc::new(launch),
1132        );
1133        Harness {
1134            app,
1135            issues,
1136            bps,
1137            logs,
1138            target_id,
1139        }
1140    }
1141
1142    async fn post_issue(h: &Harness, issue_id: &str) -> IssueId {
1143        let id = IssueId::new(issue_id);
1144        h.app
1145            .handle(EnhanceApplicationInput {
1146                blueprint_id: h.target_id.clone(),
1147                intent: "add a smoke tag".into(),
1148                issue_id: id.clone(),
1149            })
1150            .await
1151            .expect("enqueue");
1152        id
1153    }
1154
1155    /// An epoch that outruns `ttl_secs` stops the drain's wait, and that
1156    /// leaves the target Blueprint exactly where it was — the half-epoch
1157    /// question, pinned. Also pins the operator-visible surface: the
1158    /// issue's terminal reason names the ceiling, the knob, and the fact
1159    /// that the worker was not stopped (that last one is the difference
1160    /// between an operator who checks before re-posting and one who starts
1161    /// a second run on top of a live one).
1162    #[tokio::test]
1163    async fn epoch_that_outruns_ttl_secs_stops_the_wait_with_nothing_committed() {
1164        let factory = RustFnInProcessSpawnerFactory::new().register_fn("hang", |_inv| async move {
1165            // Far longer than the 1s ceiling; the timeout drops this
1166            // future rather than waiting for it, so the test does not.
1167            tokio::time::sleep(Duration::from_secs(300)).await;
1168            Ok(WorkerResult {
1169                value: json!({}),
1170                ok: true,
1171                stats: None,
1172            })
1173        });
1174        let h = harness("hang", factory, 1).await;
1175        let issue_id = post_issue(&h, "h-ceiling").await;
1176        let head_before = h.bps.read_head(&h.target_id).await.expect("head before");
1177
1178        let err = h
1179            .app
1180            .tick()
1181            .await
1182            .expect_err("an epoch past its ceiling must surface as an infra fault");
1183        assert!(
1184            matches!(
1185                err,
1186                EnhanceApplicationError::EpochCeilingExceeded { ttl_secs: 1, ref setting_id }
1187                    if setting_id == "default"
1188            ),
1189            "the ceiling must abort with EpochCeilingExceeded, got: {err}"
1190        );
1191
1192        // What an operator sees afterwards.
1193        match h.issues.status(&issue_id).await.expect("issue status") {
1194            IssueStatus::Rejected { reason } => {
1195                assert!(
1196                    reason.contains("exceeded the 1s ceiling") && reason.contains("ttl_secs"),
1197                    "the reason must name the ceiling and the knob to raise, got: {reason}"
1198                );
1199                assert!(
1200                    reason.contains("nothing was committed"),
1201                    "the reason must say the target Blueprint is untouched, got: {reason}"
1202                );
1203                // The residue, on the surface an operator actually reads.
1204                // "Timed out" reads as "it stopped" unless the text says
1205                // otherwise, and acting on that reading — re-posting at once
1206                // — is what puts a second writer under one project_root.
1207                assert!(
1208                    reason.contains("Only the wait ended"),
1209                    "the reason must say the worker may still be running, got: {reason}"
1210                );
1211                assert!(
1212                    reason.contains("second writer"),
1213                    "the reason must name the hazard a blind re-post creates, got: {reason}"
1214                );
1215            }
1216            other => panic!("a timed-out epoch must be terminal Rejected, got {other:?}"),
1217        }
1218
1219        // Nothing partial: the head is byte-identical and no version was
1220        // appended.
1221        let head_after = h.bps.read_head(&h.target_id).await.expect("head after");
1222        assert_eq!(
1223            head_before.value, head_after.value,
1224            "a fired ceiling must not write to the BlueprintStore"
1225        );
1226        assert_eq!(
1227            h.bps
1228                .history(&h.target_id, 10)
1229                .await
1230                .expect("history")
1231                .len(),
1232            1,
1233            "only the seed commit may exist after a timed-out epoch"
1234        );
1235        assert!(
1236            h.logs.list_all().await.expect("log").is_empty(),
1237            "an epoch that never reached the committer appends no log entry"
1238        );
1239    }
1240
1241    /// A fired ceiling ends the wait and leaves the worker running.
1242    ///
1243    /// This is the boundary of what `ttl_secs` is, pinned so nobody has to
1244    /// rediscover it from the timeout's name. The knob bounds a wait on an
1245    /// unresponsive Operator (model §4.4 **R5**), and that wait is an await
1246    /// point. An in-process worker is not a wait: it runs inside its own
1247    /// `tokio::spawn` (`src/worker/adapter.rs`) which is not a child of the
1248    /// `launch` future, and the `CancellationToken` it selects on is fired
1249    /// by nothing in `src/` — `Engine::cancel_task` sets a status and wakes
1250    /// pollers without touching it. So dropping the future ends this
1251    /// dispatcher's wait and nothing else.
1252    ///
1253    /// That is the fact the `EpochCeilingExceeded` text and
1254    /// `mse://guides/enhance-flow` tell an operator to act on: re-posting
1255    /// immediately puts a second writer under the same `project_root`. If
1256    /// this test ever starts failing, that guidance has become wrong and
1257    /// both surfaces have to move with the code.
1258    ///
1259    /// Real time rather than `start_paused`: the claim is about work that
1260    /// does or does not keep running, so a clock the test controls would
1261    /// prove less than the thing being asserted.
1262    #[tokio::test]
1263    async fn a_fired_ceiling_does_not_stop_the_worker() {
1264        use std::sync::atomic::{AtomicBool, Ordering};
1265
1266        let ran_past_the_ceiling = Arc::new(AtomicBool::new(false));
1267        let flag = ran_past_the_ceiling.clone();
1268        let factory = RustFnInProcessSpawnerFactory::new().register_fn("outlive", move |_inv| {
1269            let flag = flag.clone();
1270            async move {
1271                // Outlasts the 1s ceiling, so the timeout fires mid-sleep —
1272                // i.e. with the worker parked on an await, the one place a
1273                // drop could have reached it if the lanes worked that way.
1274                tokio::time::sleep(Duration::from_millis(1_600)).await;
1275                flag.store(true, Ordering::SeqCst);
1276                Ok(WorkerResult {
1277                    value: json!({}),
1278                    ok: true,
1279                    stats: None,
1280                })
1281            }
1282        });
1283        let h = harness("outlive", factory, 1).await;
1284        post_issue(&h, "h-residue").await;
1285
1286        let err = h.app.tick().await.expect_err("the ceiling must fire");
1287        assert!(
1288            matches!(err, EnhanceApplicationError::EpochCeilingExceeded { .. }),
1289            "expected the ceiling, got: {err}"
1290        );
1291        assert!(
1292            !ran_past_the_ceiling.load(Ordering::SeqCst),
1293            "precondition: the worker must still be mid-sleep when the ceiling fires, \
1294             otherwise this test proves nothing"
1295        );
1296
1297        // Give the worker more than the rest of its sleep. The flag flipping
1298        // here is the residue itself: the issue is already terminal and the
1299        // work is still going.
1300        tokio::time::sleep(Duration::from_millis(1_200)).await;
1301        assert!(
1302            ran_past_the_ceiling.load(Ordering::SeqCst),
1303            "the worker was expected to run on past the ceiling — if it stopped, the \
1304             ceiling now reaches the work and the reason text plus \
1305             mse://guides/enhance-flow, which both tell operators to check for a live \
1306             worker before re-posting, have to be corrected in the same change"
1307        );
1308    }
1309
1310    /// The counter-direction: an epoch that finishes inside the ceiling is
1311    /// unaffected by the wrap and still lands its outcome in the enhance
1312    /// log. Without this, deleting the ceiling's `Ok` arm would go
1313    /// unnoticed.
1314    #[tokio::test]
1315    async fn epoch_within_the_ceiling_still_reaches_the_committer() {
1316        let factory =
1317            RustFnInProcessSpawnerFactory::new().register_fn("commit", |_inv| async move {
1318                Ok(WorkerResult {
1319                    value: json!({
1320                        "committed": false,
1321                        "rationale": "the patch was refused",
1322                        "reasons": ["noop: patch is no-op"],
1323                        "verdicts_summary": [
1324                            {"axis": "noop", "status": "deny", "reason": "new_hash == prev_hash"}
1325                        ],
1326                    }),
1327                    ok: true,
1328                    stats: None,
1329                })
1330            });
1331        let h = harness("commit", factory, 60).await;
1332        let issue_id = post_issue(&h, "h-ok").await;
1333
1334        let outcome = h
1335            .app
1336            .tick()
1337            .await
1338            .expect("a within-ceiling epoch must not surface as an infra fault")
1339            .expect("one issue was pending");
1340        assert_eq!(outcome.issue_id, issue_id);
1341        match outcome.status {
1342            IssueStatus::Rejected { ref reason } => assert!(
1343                reason.starts_with("verifier deny:"),
1344                "a committer rejection must keep its own reason, not the ceiling's: {reason}"
1345            ),
1346            ref other => panic!("expected a verifier rejection, got {other:?}"),
1347        }
1348        assert_eq!(
1349            h.logs.list_all().await.expect("log").len(),
1350            1,
1351            "an epoch that reached the committer appends exactly one log entry"
1352        );
1353    }
1354
1355    /// `ttl_secs: 0` is refused as a typo rather than read as "no
1356    /// ceiling", and refused before any store is touched.
1357    #[tokio::test]
1358    async fn zero_ttl_secs_is_refused_and_names_the_field() {
1359        let factory =
1360            RustFnInProcessSpawnerFactory::new().register_fn("unused", |_inv| async move {
1361                Ok(WorkerResult {
1362                    value: json!({}),
1363                    ok: true,
1364                    stats: None,
1365                })
1366            });
1367        let h = harness("unused", factory, 0).await;
1368        let issue_id = post_issue(&h, "h-zero").await;
1369
1370        let err = h
1371            .app
1372            .tick()
1373            .await
1374            .expect_err("a zero ceiling must be refused, not treated as unbounded");
1375        assert!(
1376            matches!(
1377                err,
1378                EnhanceApplicationError::ZeroTtl { ref setting_id } if setting_id == "default"
1379            ),
1380            "expected ZeroTtl, got: {err}"
1381        );
1382        match h.issues.status(&issue_id).await.expect("issue status") {
1383            IssueStatus::Rejected { reason } => assert!(
1384                reason.contains("ttl_secs: 0"),
1385                "the reason must name the field and its bad value, got: {reason}"
1386            ),
1387            other => panic!("expected terminal Rejected, got {other:?}"),
1388        }
1389        assert!(
1390            h.logs.list_all().await.expect("log").is_empty(),
1391            "a refused setting never reaches the committer"
1392        );
1393    }
1394
1395    #[test]
1396    fn override_without_a_matching_agent_fails_loud() {
1397        let mut bp = default_blueprint();
1398        bp.agents.retain(|a| a.name != AG_PATCH_SPAWNER);
1399        let err = apply_spawner_override(&mut bp, Some(&subprocess_spawner(AG_PATCH_SPAWNER)))
1400            .expect_err("a missing override target must not be ignored");
1401        assert!(matches!(
1402            err,
1403            EnhanceApplicationError::SpawnerAgentNotFound { ref name } if name == AG_PATCH_SPAWNER
1404        ));
1405        assert!(err.to_string().contains(AG_PATCH_SPAWNER));
1406    }
1407}