1use 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
26const LIFECYCLE_TIMEOUT: Duration = Duration::from_secs(30);
31const 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 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 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 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 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 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 cleanup.merge(record.finish());
411 feed.abort();
412 drain_stdout.abort();
413 drain_stderr.abort();
414 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
446pub(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}