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