1use 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
21const LIFECYCLE_TIMEOUT: Duration = Duration::from_secs(30);
24const 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 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 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 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 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 cleanup.merge(record.finish());
360 feed.abort();
361 drain_stdout.abort();
362 drain_stderr.abort();
363 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
395pub(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}