Skip to main content

algocline_app/service/
execution_service_impl.rs

1//! `impl ExecutionService for AppService` — thin delegation to [`SessionRegistryV2`].
2//!
3//! All six verbs (`spawn`, `state`, `resume`, `cancel`, `observe`, `await_terminal`)
4//! delegate directly to `self.execution_registry` with no additional logic.
5//!
6//! # Crux constraints honoured
7//!
8//! - **R1 (Wire-concept exclusion)**: This file imports nothing from `rmcp`, contains
9//!   no `progressToken`, `_meta`, `notifications/`, or `mcp_`-prefixed identifiers.
10//! - **R2 (Cooperative cancellation)**: `cancel` calls
11//!   `execution_registry.cancel(id, reason)` only; no `JoinHandle::abort()` or
12//!   process-kill path exists in this file.
13//! - **R3 (Sink-free broadcast fan-out)**: `observe` is a sync `fn` that delegates
14//!   to `execution_registry.observe(id)`.  Multi-subscriber behaviour is verified by
15//!   `observe_multi_subscriber_fan_out_via_appservice` and
16//!   `observe_sink_free_when_no_subscribers` in the tests below.
17
18use algocline_core::execution::{
19    AwaitError, CancelError, CancelReason, ExecutionService, ExecutionState, ObserveError,
20    ObserverHandle, ResumeError, ResumeOutcome, ResumePayload, SessionId, SessionSpec, SpawnError,
21    StateError, TerminalOutcome,
22};
23use async_trait::async_trait;
24
25use crate::service::AppService;
26
27#[async_trait]
28impl ExecutionService for AppService {
29    async fn spawn(&self, spec: SessionSpec) -> Result<SessionId, SpawnError> {
30        // Resolve `[setting.card].run` per session so the Phase 1-B `[run]`
31        // gate follows the same cascade (env > project > global) as any
32        // other setting.  Errors on the resolver path degrade to gate-off
33        // rather than failing spawn: an unreadable config.toml must not
34        // block execution, and a missing setting still yields `None` from
35        // `get_bool` which we treat as `false`.  See Phase 1-A cascade
36        // tests in `service::setting`.
37        let app_dir = self.log_config.app_dir();
38        let card_run_enabled = crate::service::setting::resolve_setting(
39            &app_dir,
40            spec.project_root.as_deref(),
41            Some("card"),
42        )
43        .ok()
44        .and_then(|s| s.get_bool("card", "run"))
45        .unwrap_or(false);
46        self.execution_registry
47            .spawn_v2(spec, card_run_enabled)
48            .await
49    }
50
51    async fn state(&self, id: &SessionId) -> Result<ExecutionState, StateError> {
52        self.execution_registry.state(id).await
53    }
54
55    async fn resume(
56        &self,
57        id: &SessionId,
58        payload: ResumePayload,
59    ) -> Result<ResumeOutcome, ResumeError> {
60        self.execution_registry.resume(id, payload).await
61    }
62
63    async fn cancel(&self, id: &SessionId, reason: CancelReason) -> Result<(), CancelError> {
64        self.execution_registry.cancel(id, reason).await
65    }
66
67    fn observe(&self, id: &SessionId) -> Result<Box<dyn ObserverHandle>, ObserveError> {
68        self.execution_registry.observe(id)
69    }
70
71    async fn await_terminal(&self, id: &SessionId) -> Result<TerminalOutcome, AwaitError> {
72        self.execution_registry.await_terminal(id).await
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use algocline_core::execution::{
79        CancelCode, CancelReason, ExecutionService, ExecutionState, ObserverRecvError,
80        ProgressEvent, ResumePayload, SessionSpec, SpecKind, TerminalOutcome,
81    };
82
83    use crate::service::test_support::make_app_service;
84
85    fn simple_spec(code: &str) -> SessionSpec {
86        SessionSpec {
87            kind: SpecKind::Run {
88                code: code.to_owned(),
89            },
90            project_root: None,
91            ctx: None,
92        }
93    }
94
95    fn user_cancel_reason() -> CancelReason {
96        CancelReason {
97            code: CancelCode::User,
98            detail: None,
99            requested_at: std::time::SystemTime::now()
100                .duration_since(std::time::UNIX_EPOCH)
101                .unwrap_or_default()
102                .as_millis() as i64,
103        }
104    }
105
106    // -----------------------------------------------------------------------
107    // spawn_returns_session_id_and_running_state
108    // -----------------------------------------------------------------------
109
110    /// `spawn()` → `state()` immediately returns Running (or Paused for LLM-touching scripts).
111    #[tokio::test]
112    async fn spawn_returns_session_id_and_running_state() {
113        let svc = make_app_service().await;
114        // Use a Lua script that pauses so we can observe the state before completion.
115        let sid = svc
116            .spawn(simple_spec(r#"return alc.llm("q")"#))
117            .await
118            .expect("spawn must succeed");
119
120        // Wait a short time for the driver to start.
121        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
122
123        let state = svc.state(&sid).await.expect("state must succeed");
124        assert!(
125            matches!(state, ExecutionState::Running | ExecutionState::Paused(_)),
126            "state immediately after spawn must be Running or Paused, got: {:?}",
127            state.tag()
128        );
129    }
130
131    // -----------------------------------------------------------------------
132    // spawn_run_lua_to_completion
133    // -----------------------------------------------------------------------
134
135    /// `SpecKind::Run { code: "return 42" }` → `await_terminal()` → `Terminal(Done(value=42))`.
136    #[tokio::test]
137    async fn spawn_run_lua_to_completion() {
138        let svc = make_app_service().await;
139        let sid = svc
140            .spawn(simple_spec("return 42"))
141            .await
142            .expect("spawn must succeed");
143
144        let outcome =
145            tokio::time::timeout(std::time::Duration::from_secs(5), svc.await_terminal(&sid))
146                .await
147                .expect("await_terminal must not timeout")
148                .expect("await_terminal must succeed");
149
150        match outcome {
151            TerminalOutcome::Done(result) => {
152                assert_eq!(
153                    result.value,
154                    serde_json::json!(42),
155                    "Done result value must be 42"
156                );
157            }
158            other => panic!("expected Done, got: {other:?}"),
159        }
160    }
161
162    // -----------------------------------------------------------------------
163    // spawn_lua_pause_publishes_progress_event
164    // -----------------------------------------------------------------------
165
166    /// Lua `alc.llm(...)` causes a pause → `observe()` receiver gets `ProgressEvent::PauseRequested`.
167    #[tokio::test]
168    async fn spawn_lua_pause_publishes_progress_event() {
169        let svc = make_app_service().await;
170
171        let sid = svc
172            .spawn(simple_spec(r#"return alc.llm("tell me something")"#))
173            .await
174            .expect("spawn must succeed");
175
176        // Subscribe before the pause event is published.
177        let mut handle = svc.observe(&sid).expect("observe must succeed");
178
179        // Wait for the PauseRequested event (with timeout).
180        let got_pause = tokio::time::timeout(std::time::Duration::from_secs(5), async {
181            loop {
182                match handle.recv().await {
183                    Ok(ProgressEvent::PauseRequested { .. }) => return true,
184                    Ok(_) => {}
185                    Err(ObserverRecvError::Closed) => return false,
186                    Err(ObserverRecvError::Lagged(_)) => {}
187                }
188            }
189        })
190        .await
191        .expect("must not timeout waiting for PauseRequested");
192
193        assert!(got_pause, "must receive PauseRequested event");
194    }
195
196    // -----------------------------------------------------------------------
197    // resume_continues_paused_session
198    // -----------------------------------------------------------------------
199
200    /// Pause → resume(`ResumePayload::Single { response, ... }`) → `ResumeOutcome::Continued`
201    /// and session eventually reaches `Done`.
202    #[tokio::test]
203    async fn resume_continues_paused_session() {
204        use algocline_core::execution::ResumeOutcome;
205
206        let svc = make_app_service().await;
207
208        // Strategy that calls alc.llm once and returns the response.
209        let sid = svc
210            .spawn(simple_spec(r#"return alc.llm("what is 1+1?")"#))
211            .await
212            .expect("spawn must succeed");
213
214        // Wait until the session reaches Paused, capturing the query_id.
215        let query_id = tokio::time::timeout(std::time::Duration::from_secs(5), async {
216            loop {
217                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
218                let state = svc.state(&sid).await.expect("state");
219                if let ExecutionState::Paused(info) = state {
220                    // The query id is the first pending prompt's id.
221                    if let Some(prompt) = info.prompts.first() {
222                        return prompt.query_id.clone();
223                    }
224                }
225            }
226        })
227        .await
228        .expect("must reach Paused state within timeout");
229
230        let outcome = svc
231            .resume(
232                &sid,
233                ResumePayload::Single {
234                    query_id,
235                    response: "2".into(),
236                    usage: None,
237                },
238            )
239            .await
240            .expect("resume must succeed");
241
242        assert!(
243            matches!(outcome, ResumeOutcome::Continued),
244            "resume must return Continued, got: {outcome:?}"
245        );
246
247        // The session should now complete.
248        let terminal =
249            tokio::time::timeout(std::time::Duration::from_secs(5), svc.await_terminal(&sid))
250                .await
251                .expect("await_terminal must not timeout")
252                .expect("await_terminal must succeed");
253
254        assert!(
255            matches!(terminal, TerminalOutcome::Done(_)),
256            "session must complete as Done after resume, got: {terminal:?}"
257        );
258    }
259
260    // -----------------------------------------------------------------------
261    // cancel_running_session_transitions_to_cancelled
262    // -----------------------------------------------------------------------
263
264    /// Paused Lua session → `cancel()` → `await_terminal()` → `Cancelled(info)`
265    /// with `info.reason.code == CancelCode::User`.
266    ///
267    /// Uses `alc.llm(...)` to enter Paused state so that `cancel()` can exercise
268    /// the direct Paused→Cancelled transition in `registry::cancel()` without
269    /// requiring the driver to reach a cooperative-cancellation checkpoint
270    /// (which a tight CPU loop would never yield to).
271    #[tokio::test]
272    async fn cancel_running_session_transitions_to_cancelled() {
273        let svc = make_app_service().await;
274
275        // Spawn a session that pauses waiting for an LLM response.
276        let sid = svc
277            .spawn(simple_spec(r#"return alc.llm("cancel me")"#))
278            .await
279            .expect("spawn must succeed");
280
281        // Wait until the session reaches Paused state.
282        tokio::time::timeout(std::time::Duration::from_secs(5), async {
283            loop {
284                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
285                let state = svc.state(&sid).await.expect("state");
286                if matches!(state, ExecutionState::Paused(_)) {
287                    return;
288                }
289            }
290        })
291        .await
292        .expect("session must reach Paused state within timeout");
293
294        svc.cancel(&sid, user_cancel_reason())
295            .await
296            .expect("cancel must succeed");
297
298        let terminal =
299            tokio::time::timeout(std::time::Duration::from_secs(5), svc.await_terminal(&sid))
300                .await
301                .expect("await_terminal must not timeout")
302                .expect("await_terminal must succeed");
303
304        match terminal {
305            TerminalOutcome::Cancelled(info) => {
306                assert_eq!(
307                    info.reason.code,
308                    CancelCode::User,
309                    "cancel code must be User"
310                );
311            }
312            other => panic!("expected Cancelled, got: {other:?}"),
313        }
314    }
315
316    // -----------------------------------------------------------------------
317    // cancel_idempotent_returns_ok  (Crux R2)
318    // -----------------------------------------------------------------------
319
320    /// Calling `cancel()` twice on the same paused session must both return `Ok(())`.
321    /// The first `CancelInfo` must remain in the terminal state.
322    #[tokio::test]
323    async fn cancel_idempotent_returns_ok() {
324        let svc = make_app_service().await;
325
326        // Spawn a session that pauses waiting for an LLM response.
327        let sid = svc
328            .spawn(simple_spec(r#"return alc.llm("cancel me")"#))
329            .await
330            .expect("spawn must succeed");
331
332        // Wait until the session reaches Paused state.
333        tokio::time::timeout(std::time::Duration::from_secs(5), async {
334            loop {
335                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
336                let state = svc.state(&sid).await.expect("state");
337                if matches!(state, ExecutionState::Paused(_)) {
338                    return;
339                }
340            }
341        })
342        .await
343        .expect("session must reach Paused state within timeout");
344
345        svc.cancel(&sid, user_cancel_reason())
346            .await
347            .expect("first cancel must return Ok");
348
349        // Second cancel on the same session must also return Ok.
350        svc.cancel(&sid, user_cancel_reason())
351            .await
352            .expect("second cancel must return Ok (idempotent)");
353
354        // State must reflect the first cancel's info.
355        let terminal =
356            tokio::time::timeout(std::time::Duration::from_secs(5), svc.await_terminal(&sid))
357                .await
358                .expect("await_terminal must not timeout")
359                .expect("await_terminal must succeed");
360
361        assert!(
362            matches!(terminal, TerminalOutcome::Cancelled(_)),
363            "session must be Cancelled, got: {terminal:?}"
364        );
365    }
366
367    // -----------------------------------------------------------------------
368    // observe_multi_subscriber_fan_out_via_appservice  (Crux R3)
369    // -----------------------------------------------------------------------
370
371    /// Two independent `observe()` calls must each receive the full event stream.
372    /// Neither subscriber affects the other.
373    #[tokio::test]
374    async fn observe_multi_subscriber_fan_out_via_appservice() {
375        let svc = make_app_service().await;
376
377        let sid = svc
378            .spawn(simple_spec("return 99"))
379            .await
380            .expect("spawn must succeed");
381
382        // Subscribe two independent handles before the session terminates.
383        let mut h1 = svc.observe(&sid).expect("observe h1 must succeed");
384        let mut h2 = svc.observe(&sid).expect("observe h2 must succeed");
385
386        // Wait for terminal so events have been published.
387        let _ = tokio::time::timeout(std::time::Duration::from_secs(5), svc.await_terminal(&sid))
388            .await
389            .expect("await_terminal must not timeout");
390
391        // Each handle must receive at least one StateTransition event.
392        //
393        // The recv loop is bounded by a 100ms idle-timeout per receive:
394        // `SessionRegistryV2` is sink-free (registry retains `bus_tx` so late
395        // `observe()` calls can still subscribe), so `bus_tx` does NOT drop
396        // when the session reaches a terminal state — `Closed` would never
397        // fire and a bare `loop { recv().await }` blocks forever (the
398        // observed 7-min worktree hang). Timeout exit is correct: after
399        // `await_terminal()` succeeded, all events have been published, so
400        // a 100ms idle window past the last event proves nothing more is
401        // coming.
402        use std::time::Duration;
403        for (label, handle) in [("h1", &mut h1), ("h2", &mut h2)] {
404            let mut got_transition = false;
405            loop {
406                match tokio::time::timeout(Duration::from_millis(100), handle.recv()).await {
407                    Ok(Ok(ProgressEvent::StateTransition { .. })) => got_transition = true,
408                    Ok(Ok(_)) => {}
409                    Ok(Err(ObserverRecvError::Closed)) => break,
410                    Ok(Err(ObserverRecvError::Lagged(_))) => {}
411                    Err(_) => break, // idle-timeout: no more events coming
412                }
413            }
414            assert!(
415                got_transition,
416                "{label}: must receive at least one StateTransition event"
417            );
418        }
419    }
420
421    // -----------------------------------------------------------------------
422    // observe_sink_free_when_no_subscribers  (Crux R3 + debt #3)
423    // -----------------------------------------------------------------------
424
425    /// Spawn a session without calling `observe()` first.  `await_terminal()` must
426    /// complete normally (sink-free: 0 observers do not stall execution).
427    ///
428    /// After terminal and GC eviction, `observe()` must return **strictly**
429    /// `ObserveError::NotFound` (debt #3 resolution / Crux #2 TTL override for
430    /// test determinism).
431    ///
432    /// This test uses `SessionRegistryV2` directly (not `make_app_service`) so it
433    /// can inject a sub-second TTL and interval without waiting for the production
434    /// 3h TTL (R5 fallback: direct registry construction path).
435    #[tokio::test]
436    async fn observe_sink_free_when_no_subscribers() {
437        use algocline_core::execution::ObserveError;
438        use std::sync::Arc;
439        use std::time::Duration;
440
441        // Build a registry with per-test temp storage (does not touch ~/.algocline).
442        let executor = Arc::new(
443            algocline_engine::Executor::new(vec![])
444                .await
445                .expect("Executor::new"),
446        );
447        let tmp = tempfile::tempdir().expect("tempdir");
448        let state_store = Arc::new(algocline_engine::JsonFileStore::new(
449            tmp.path().join("state"),
450        ));
451        let card_store = Arc::new(algocline_engine::FileCardStore::new(
452            tmp.path().join("cards"),
453        ));
454        let scenarios_dir = tmp.path().join("scenarios");
455        let nn_dir = tmp.path().join("nn");
456        let dirs = algocline_engine::SessionDirs {
457            state_store,
458            card_store,
459            scenarios_dir,
460            nn_dir,
461        };
462        let registry = algocline_engine::execution::SessionRegistryV2::new(executor, dirs);
463
464        let ttl = Duration::from_millis(100);
465        let interval = Duration::from_millis(50);
466
467        let sid = registry
468            .spawn_v2(simple_spec("return 1"), false)
469            .await
470            .expect("spawn must succeed");
471
472        // Do NOT observe — no subscribers at all (sink-free contract).
473        let terminal = tokio::time::timeout(Duration::from_secs(5), registry.await_terminal(&sid))
474            .await
475            .expect("await_terminal must not timeout even with 0 observers")
476            .expect("await_terminal must succeed");
477
478        assert!(
479            matches!(terminal, TerminalOutcome::Done(_)),
480            "session must complete as Done, got: {terminal:?}"
481        );
482
483        // Wire GC with sub-second TTL + interval for deterministic eviction.
484        registry.spawn_gc_task(ttl, interval);
485
486        // Sleep beyond interval + ttl + slack so at least one GC tick fires
487        // after TTL has elapsed (R4 guard: avoids false-positive from first tick).
488        tokio::time::sleep(interval + ttl + Duration::from_millis(50)).await;
489
490        // After eviction, observe must return NotFound strictly (debt #3).
491        assert!(
492            matches!(registry.observe(&sid), Err(ObserveError::NotFound(_))),
493            "observe() must return NotFound after GC evicts the terminal session"
494        );
495    }
496}