Skip to main content

artifactize_core/tools/
sessions.rs

1//! Review-owned tool sessions. A detached supervisor holds each process group and
2//! runs stop even when its review future is dropped or its token is cancelled.
3
4use std::{collections::BTreeMap, ffi::OsString, io, path::Path, time::Duration};
5
6use tokio::{
7    io::AsyncReadExt,
8    sync::{Mutex, oneshot},
9    task::JoinHandle,
10    time::Instant,
11};
12use tokio_util::sync::{CancellationToken, DropGuard};
13
14mod journal;
15pub(super) mod ownership;
16use journal::{Record, allocate};
17pub(crate) use ownership::executor;
18use ownership::{Cleanup, Ownership};
19
20use super::{Invocation, Registry};
21use crate::{
22    config::{AgentTool, ArtifactName, ToolOperationName},
23    platform, process, runtime, workspace,
24};
25
26/// Spawning a session's start and running its stop each get thirty seconds, so a
27/// broken harness cannot delay cleanup indefinitely. Readiness has no fixed bound:
28/// preparing a harness may take as long as it needs while its start lives, and the
29/// review's cancellation or the start's exit ends the wait.
30const LIFECYCLE_TIMEOUT: Duration = Duration::from_secs(30);
31/// Drain without retaining untrusted harness output or allocating for a whole line.
32const DRAIN_BYTES: usize = 8192;
33
34#[derive(Default)]
35pub struct Sessions(BTreeMap<(ArtifactName, ToolOperationName), Mutex<Session>>);
36
37enum Session {
38    Pending(Box<Start>),
39    Starting {
40        running: Running,
41        ready: oneshot::Receiver<Result<BTreeMap<OsString, OsString>, String>>,
42    },
43    Ready {
44        running: Running,
45        environment: BTreeMap<OsString, OsString>,
46    },
47    Failed {
48        running: Option<Running>,
49        error: String,
50    },
51}
52
53struct Start {
54    commands: Result<(Invocation, Invocation), String>,
55    workspace: std::path::PathBuf,
56    state: std::path::PathBuf,
57    cancellation: CancellationToken,
58}
59
60struct Running {
61    owner: std::sync::Arc<Ownership>,
62    stop: DropGuard,
63    finished: JoinHandle<Result<(), String>>,
64}
65
66impl Start {
67    async fn spawn(self) -> Result<Session, String> {
68        let (start, stop) = self.commands?;
69        let root = workspace::prepare_directory(&self.state.join("tool-sessions"), &self.workspace)
70            .map_err(|error| error.to_string())?;
71        let (directory, lease) =
72            crate::task::joined(tokio::task::spawn_blocking(move || allocate(&root)).await)
73                .map_err(|error| error.to_string())?
74                .map_err(|error| error.to_string())?;
75        let stop_token = CancellationToken::new();
76        let guard = stop_token.clone().drop_guard();
77        let owner = Ownership::new(directory.path(), lease);
78        let (send, ready) = oneshot::channel();
79        let finished = executor()
80            .map_err(|error| error.to_string())?
81            .spawn(supervise(
82                start,
83                stop,
84                self.workspace,
85                directory,
86                self.cancellation,
87                stop_token,
88                owner.clone(),
89                send,
90            ));
91        Ok(Session::Starting {
92            running: Running {
93                owner,
94                stop: guard,
95                finished,
96            },
97            ready,
98        })
99    }
100}
101
102impl Session {
103    fn failed(error: String) -> Self {
104        Self::Failed {
105            running: None,
106            error,
107        }
108    }
109
110    fn running(self) -> Option<Running> {
111        match self {
112            Self::Pending(_) => None,
113            Self::Starting { running, .. } | Self::Ready { running, .. } => Some(running),
114            Self::Failed { running, .. } => running,
115        }
116    }
117}
118
119impl Sessions {
120    pub(crate) async fn call(
121        &self,
122        name: &(ArtifactName, ToolOperationName),
123    ) -> Result<ownership::Call, String> {
124        let ended = || format!("The tool session `{}` ended.", name.1);
125        let session = self.0.get(name).ok_or_else(ended)?;
126        // Keep startup and readiness serialized, including if tools later allow parallel calls.
127        let mut session = session.lock().await;
128        if matches!(*session, Session::Pending(_)) {
129            let Session::Pending(start) =
130                std::mem::replace(&mut *session, Session::failed("start was dropped".into()))
131            else {
132                unreachable!()
133            };
134            *session = start.spawn().await.unwrap_or_else(Session::failed);
135        }
136        if let Session::Starting { ready, .. } = &mut *session {
137            // Store the supervisor before waiting: a dropped call still leaves close
138            // owning its stop guard and join handle, even before readiness.
139            let ready = ready.await.unwrap_or_else(|error| Err(error.to_string()));
140            let running =
141                std::mem::replace(&mut *session, Session::failed("start was dropped".into()))
142                    .running()
143                    .expect("starting session owns its supervisor");
144            *session = match ready {
145                Ok(environment) => Session::Ready {
146                    running,
147                    environment,
148                },
149                Err(error) => Session::Failed {
150                    running: Some(running),
151                    error,
152                },
153            };
154        }
155        match &*session {
156            Session::Ready {
157                running,
158                environment,
159            } => running.owner.call(environment).ok_or_else(ended),
160            Session::Failed { error, .. } => Err(format!("Tool session {}: {error}", name.1)),
161            _ => unreachable!("session startup resolved"),
162        }
163    }
164
165    pub async fn close(self) -> Result<(), String> {
166        let finished: Vec<_> = self
167            .0
168            .into_values()
169            .filter_map(|session| session.into_inner().running())
170            .map(|running| {
171                drop(running.stop);
172                running.finished
173            })
174            .collect();
175        let mut result = Ok(());
176        for task in finished {
177            let cleaned = crate::task::joined(task.await)
178                .map_err(|error| error.to_string())
179                .and_then(|result| result);
180            if result.is_ok() {
181                result = cleaned;
182            }
183        }
184        result
185    }
186}
187
188impl Registry<'_> {
189    /// Register lazy sessions for admitted tools, or the selected diagnostic tool.
190    /// No process or session directory is created until a bound call.
191    pub fn sessions(
192        &self,
193        state: &Path,
194        selected: Option<&str>,
195        cancellation: CancellationToken,
196    ) -> Sessions {
197        let mut sessions = Sessions::default();
198        for (tool_name, tool) in &self.tools {
199            if selected.is_some_and(|selected| tool_name != selected) {
200                continue;
201            }
202            let AgentTool::Command(tool_declaration) = tool.declaration else {
203                continue;
204            };
205            let Some(name) = &tool_declaration.session else {
206                continue;
207            };
208            let owner = &tool.definition.artifact_id;
209            let key = session_key(name, owner);
210            sessions.0.entry(key).or_insert_with(|| {
211                let declaration = &self.scope.artifacts[owner].views.agent_sessions[name];
212                let commands = self
213                    .prepare_session(owner, &declaration.start)
214                    .and_then(|start| {
215                        self.prepare_session(owner, &declaration.stop)
216                            .map(|stop| (start, stop))
217                    });
218                Mutex::new(Session::Pending(Box::new(Start {
219                    commands,
220                    workspace: self.config.root.clone(),
221                    state: state.to_owned(),
222                    cancellation: cancellation.clone(),
223                })))
224            });
225        }
226        sessions
227    }
228}
229
230pub(super) fn session_key(
231    name: &crate::config::ToolOperationName,
232    owner: &crate::config::ArtifactName,
233) -> (ArtifactName, ToolOperationName) {
234    (owner.clone(), name.clone())
235}
236
237#[expect(
238    clippy::too_many_arguments,
239    reason = "a supervisor owns its two commands, directory, independent stop and readiness channels"
240)]
241async fn supervise(
242    start: Invocation,
243    stop: Invocation,
244    workspace: std::path::PathBuf,
245    directory: platform::PrivateTempDir,
246    cancellation: CancellationToken,
247    stop_token: CancellationToken,
248    owner: std::sync::Arc<Ownership>,
249    send: oneshot::Sender<Result<BTreeMap<OsString, OsString>, String>>,
250) -> Result<(), String> {
251    // Once a supervisor may admit a process, only confirmed cleanup may delete
252    // its journal. Panic or an unexpected return must not invoke TempDir::drop.
253    let path = directory.keep();
254    let mut cleanup = supervise_inner(
255        start,
256        stop,
257        &workspace,
258        &path,
259        cancellation,
260        stop_token,
261        owner,
262        send,
263    )
264    .await;
265    if cleanup.confirmed()
266        && let Err(error) = std::fs::remove_dir_all(&path)
267        && error.kind() != io::ErrorKind::NotFound
268    {
269        cleanup.merge(Cleanup::error(error));
270    }
271    cleanup.result()
272}
273
274#[expect(
275    clippy::too_many_arguments,
276    reason = "the supervisor's command, lifecycle and readiness state"
277)]
278async fn supervise_inner(
279    start: Invocation,
280    stop: Invocation,
281    workspace: &Path,
282    directory: &Path,
283    cancellation: CancellationToken,
284    stop_token: CancellationToken,
285    owner: std::sync::Arc<Ownership>,
286    send: oneshot::Sender<Result<BTreeMap<OsString, OsString>, String>>,
287) -> Cleanup {
288    let prepared = prepare(start, workspace, directory, None);
289    let (command, input) = match prepared {
290        Ok(prepared) => prepared,
291        Err(error) => {
292            let _ = send.send(Err(error));
293            return Cleanup::default();
294        }
295    };
296    let environment = command.env.clone();
297    let record = Record::start(directory);
298    let admitted = record.clone();
299    let deadline = Instant::now() + LIFECYCLE_TIMEOUT;
300    let child = process::spawn_owned(
301        &command,
302        true,
303        cancellation.clone(),
304        deadline,
305        move |leader| async move { admitted.register(leader).await },
306    )
307    .await;
308    let mut child = match child {
309        Ok(child) => child,
310        Err(error) => {
311            let _ = send.send(Err(error.to_string()));
312            record.after_run::<()>(&Err(error));
313            return record.finish();
314        }
315    };
316    let mut stdout = child.stdout().take().expect("stdout is piped");
317    let mut stderr = child.stderr().take().expect("stderr is piped");
318    let mut stdin = child.stdin().take().expect("stdin is piped");
319    let (ready_send, ready_receive) = oneshot::channel();
320    let drain_stdout = tokio::spawn(async move {
321        let ready = async {
322            let mut buffer = [0; DRAIN_BYTES];
323            loop {
324                let count = stdout.read(&mut buffer).await?;
325                if count == 0 {
326                    return Err(io::Error::other("exited before readiness"));
327                }
328                if buffer[..count].contains(&b'\n') {
329                    return Ok(());
330                }
331            }
332        }
333        .await;
334        let _ = ready_send.send(ready);
335        let _ = tokio::io::copy(&mut stdout, &mut tokio::io::sink()).await;
336    });
337    let drain_stderr = tokio::spawn(async move {
338        let _ = tokio::io::copy(&mut stderr, &mut tokio::io::sink()).await;
339    });
340    let feed = tokio::spawn(async move {
341        let result = tokio::io::AsyncWriteExt::write_all(&mut stdin, &input).await;
342        drop(stdin);
343        result
344    });
345    let ready = tokio::select! {
346        biased;
347        _ = cancellation.cancelled() => Err("start was cancelled".to_owned()),
348        _ = stop_token.cancelled() => Err("start was dropped".to_owned()),
349        status = child.wait_leader() => Err(match status {
350            Ok(status) => format!("exited before readiness: {}", platform::exit_description(&status)),
351            Err(error) => format!("could not wait for the start command: {error}"),
352        }),
353        result = ready_receive => match result {
354            Ok(Ok(())) => match child.try_wait_leader() {
355                Ok(None) => Ok(()),
356                _ => Err("exited before readiness".into()),
357            },
358            Ok(Err(error)) => Err(error.to_string()),
359            Err(error) => Err(error.to_string()),
360        }
361    };
362    let mut child = Some(child);
363    let mut cleanup = Cleanup::default();
364    let started = ready.is_ok();
365    let _ = send.send(ready.map(|()| environment.clone()));
366    if started {
367        tokio::select! {
368            biased;
369            status = child.as_mut().expect("owned start").wait_leader() => {
370                owner.ended.cancel();
371                match child.as_mut().expect("owned start").cleanup().await {
372                    Ok(_) => {
373                        record.confirm();
374                        drop(child.take());
375                        cleanup.merge(record.finish());
376                    },
377                    Err(error) => cleanup.merge(Cleanup::error(error)),
378                }
379                let _ = status;
380                stop_token.cancelled().await;
381            },
382            _ = stop_token.cancelled() => {},
383        }
384    }
385    cleanup.merge(owner.finish_calls().await);
386    // Stop ignores the review's cancelled token. Its own one-shot supervisor bounds
387    // the command, then the session group is killed regardless of its outcome.
388    if let Ok((command, input)) = prepare(stop, workspace, directory, Some(&environment)) {
389        let stopped = Record::new(directory);
390        let admitted = stopped.clone();
391        let result = process::run_with_input_limit(
392            command,
393            Some(input),
394            0,
395            CancellationToken::new(),
396            move |child| async move { admitted.register(child).await },
397        )
398        .await;
399        stopped.after_run(&result);
400        cleanup.merge(stopped.finish());
401    }
402    if let Some(mut child) = child {
403        match child.cleanup().await {
404            Ok(_) => record.confirm(),
405            Err(error) => cleanup.merge(Cleanup::error(error)),
406        }
407    }
408    // A failed signal/wait leaves Record owned; finish marks cleanup incomplete
409    // and keeps every journal even after this supervisor/lease is released.
410    cleanup.merge(record.finish());
411    feed.abort();
412    drain_stdout.abort();
413    drain_stderr.abort();
414    // Aborted tasks end cancelled, which is expected; a panic in one continues here.
415    let _ = crate::task::joined(feed.await);
416    let _ = crate::task::joined(drain_stdout.await);
417    let _ = crate::task::joined(drain_stderr.await);
418    cleanup
419}
420
421fn prepare(
422    mut invocation: Invocation,
423    workspace: &Path,
424    directory: &Path,
425    environment: Option<&BTreeMap<OsString, OsString>>,
426) -> Result<(process::Command, Vec<u8>), String> {
427    let mut command = runtime::Command::prepare(
428        invocation.program,
429        invocation.argv,
430        workspace,
431        directory,
432        Some(LIFECYCLE_TIMEOUT),
433    )
434    .map_err(|error| error.to_string())?;
435    command.cwd = invocation.cwd;
436    command.session_environment(directory);
437    if let Some(environment) = environment {
438        command.inherit_session(environment);
439    }
440    invocation.context.output_dir = Some(command.directory().join("output"));
441    invocation.context.tmp_dir = Some(command.directory().join("tmp"));
442    let input = serde_json::json!({"version": super::JSON_TOOL_INPUT_VERSION, "context": invocation.context, "args": {}}).to_string().into_bytes();
443    Ok((command.into_process(), input))
444}
445
446/// Recover abandoned session journals through their existing identity-checked allocation sweep.
447pub(crate) fn recover(state: &Path) -> io::Result<()> {
448    let root = state.join("tool-sessions");
449    if root.is_dir() {
450        let _ = allocate(&root)?;
451    }
452    Ok(())
453}