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}