scv-tools 0.4.7

Workspace-scoped filesystem, process, skill, and agent tools for SCV
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
//! Long-running delegated agents: one child process per conversation, spoken
//! to in newline-delimited JSON over its stdin and stdout.
//!
//! [`LiveChild`] is protocol-neutral. It starts the child in its adapter
//! environment and process group, records it as a delegation (so `scv agents
//! ps`, `kill`, reconcile, and orphan reaping apply), frames its stdout into
//! bounded lines, and shuts it down: closing stdin, a grace period, then a
//! group kill. A protocol client such as the SCV one in `scv_agent` (or an
//! ACP JSON-RPC client) runs on top of it.
//!
//! A conversation keeps its child between turns, and its delegation record
//! says so ([`LiveChild::begin_turn`]); the protocol client decides whether
//! anything reads from the child then.
//! A reaper task owns the process and waits for it from the start: whenever
//! the child exits, whether it ends by itself or `scv agents kill` stops it,
//! the reaper collects it at once, stops what is left of its group, and
//! removes its delegation record.

use std::{
    ffi::OsString,
    os::unix::process::CommandExt as _,
    path::PathBuf,
    sync::{
        Arc, Mutex as StdMutex,
        atomic::{AtomicBool, Ordering},
    },
    time::Duration,
};

use scv_core::ToolError;
use scv_protocol::{Frame, FrameDecoder, Overflow};
use serde::Serialize;
use tokio::{
    io::{AsyncWriteExt as _, BufReader},
    process::{Child, ChildStdin, ChildStdout, Command},
    sync::{Mutex, mpsc, watch},
    time::Instant,
};
use tokio_util::sync::CancellationToken;

use crate::{
    delegate::{
        activity::RunActivity,
        output::TailBuffer,
        records::{
            self, DelegationGuard, DelegationRegistry, PendingDelegation, STOP_GRACE, group_exists,
        },
    },
    process::{ProcessGroup, apply_agent_environment, drain_output, until},
    sync::lock,
};

/// Last bytes of a live child's stderr kept for failure reports.
const STDERR_TAIL_BYTES: usize = 4096;
/// Lines read ahead of the protocol client, a burst of events within one
/// turn. A child that speaks between turns needs a client reading then.
const LINE_QUEUE: usize = 256;
/// How long a message the protocol owes the child may take to write before
/// the child counts as not reading its input.
pub(crate) const SEND_GRACE: Duration = Duration::from_secs(2);

/// How to start a live child.
pub(crate) struct LiveSpec {
    pub(crate) executable: OsString,
    pub(crate) args: Vec<OsString>,
    pub(crate) cwd: PathBuf,
    /// The adapter environment plus the delegation tags.
    pub(crate) environment: Vec<(OsString, OsString)>,
    /// Longest stdout line accepted; a longer one ends the child.
    pub(crate) max_line_bytes: usize,
    /// The run's activity log, ended when the child exits.
    pub(crate) activity: Option<RunActivity>,
}

/// One line from a live child's stdout.
#[derive(Debug)]
pub(crate) enum LiveLine {
    Line(Vec<u8>),
    /// A line longer than [`LiveSpec::max_line_bytes`]; nothing more is read.
    TooLong,
}

/// Where a live child's process is in its life.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Life {
    Running,
    /// The leader exited and was collected; cleanup is under way.
    Exited,
    /// Its group is stopped and its delegation record removed.
    Finished,
}

/// A running live child. Dropping the last reference shuts it down in the
/// background; [`LiveChild::close`] does the same and waits for it.
pub(crate) struct LiveChild {
    pid: u32,
    stdin: Mutex<Option<ChildStdin>>,
    lines: Mutex<mpsc::Receiver<LiveLine>>,
    stderr: Arc<Mutex<TailBuffer>>,
    /// Shared with the reaper, which finishes it when the child exits.
    guard: Arc<StdMutex<Option<DelegationGuard>>>,
    life: watch::Receiver<Life>,
    closed: AtomicBool,
    activity: Option<RunActivity>,
}

impl std::fmt::Debug for LiveChild {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("LiveChild")
            .field("pid", &self.pid)
            .field("closed", &self.closed.load(Ordering::Relaxed))
            .finish()
    }
}

impl LiveChild {
    /// Start the child in its own process group with the agent environment,
    /// recording it under `registration` when given. Recording never fails
    /// the start: an unrecorded child is still tagged for later sweeps.
    pub(crate) async fn spawn(
        mut spec: LiveSpec,
        registration: Option<(Arc<DelegationRegistry>, PendingDelegation)>,
        cancellation: tokio_util::sync::CancellationToken,
    ) -> Result<Arc<Self>, ToolError> {
        let project = crate::project_environment::resolve(
            &spec.cwd,
            &spec.environment,
            cancellation.clone().cancelled_owned(),
        )
        .await
        .map_err(|error| {
            if cancellation.is_cancelled() {
                ToolError::cancelled("Rust preflight cancelled")
            } else {
                ToolError::unavailable(format!("{error:#}"))
            }
        })?;
        if cancellation.is_cancelled() {
            return Err(ToolError::cancelled("agent launch cancelled"));
        }
        spec.environment.extend(project.environment);
        let mut command = Command::new(&spec.executable);
        apply_agent_environment(command.as_std_mut(), &spec.environment);
        command
            .args(&spec.args)
            .current_dir(&spec.cwd)
            .stdin(std::process::Stdio::piped())
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::piped())
            .kill_on_drop(true);
        command.as_std_mut().process_group(0);
        let mut child = command.spawn().map_err(|error| {
            ToolError::unavailable(format!("launch {:?}: {error}", spec.executable))
        })?;
        let pid = child
            .id()
            .ok_or_else(|| ToolError::failed("child process has no pid"))?;
        records::track_spawned(pid);
        let guard = Arc::new(StdMutex::new(
            registration.and_then(|(registry, pending)| registry.register(pending, pid).ok()),
        ));
        let stdin = child.stdin.take();
        let stdout = child.stdout.take();
        let stderr = Arc::new(Mutex::new(TailBuffer::new(STDERR_TAIL_BYTES)));
        if let Some(reader) = child.stderr.take() {
            tokio::spawn(drain_output(reader, Arc::clone(&stderr)));
        }
        let (sender, receiver) = mpsc::channel(LINE_QUEUE);
        if let Some(stdout) = stdout {
            tokio::spawn(read_lines(stdout, sender, spec.max_line_bytes));
        }
        let (life_tx, life) = watch::channel(Life::Running);
        tokio::spawn(reap(
            pid,
            child,
            Arc::clone(&guard),
            life_tx,
            spec.activity.clone(),
        ));
        Ok(Arc::new(Self {
            pid,
            stdin: Mutex::new(stdin),
            lines: Mutex::new(receiver),
            stderr,
            guard,
            life,
            closed: AtomicBool::new(false),
            activity: spec.activity,
        }))
    }

    /// Write `message` as one JSON line that the protocol owes the child,
    /// such as an answer to its request or a cancellation: within
    /// [`SEND_GRACE`] of now, whatever the turn's deadline, and never given
    /// up for the turn's cancellation, which is often what made it due.
    pub(crate) async fn send(&self, message: &impl Serialize) -> Result<(), ToolError> {
        self.send_until(
            message,
            Some(Instant::now() + SEND_GRACE),
            &CancellationToken::new(),
        )
        .await
    }

    /// Write `message` as one JSON line, giving up at `deadline` or on
    /// `cancellation`, for a request that starts work. The write is tried
    /// before either is checked, so one the child can take at once is not
    /// lost to a deadline or token that is also due. A write given up
    /// part-way would leave the child half a line, so its input is closed
    /// instead: the child is lost rather than sent a garbled message.
    pub(crate) async fn send_until(
        &self,
        message: &impl Serialize,
        deadline: Option<Instant>,
        cancellation: &CancellationToken,
    ) -> Result<(), ToolError> {
        let mut bytes = serde_json::to_vec(message)
            .map_err(|error| ToolError::failed(format!("encode message: {error}")))?;
        bytes.push(b'\n');
        let mut stdin = tokio::select! {
            biased;
            stdin = self.stdin.lock() => stdin,
            () = until(deadline) => return Err(ToolError::limit("write to child timed out")),
            () = cancellation.cancelled() => return Err(ToolError::cancelled("write to child cancelled")),
        };
        let writer = stdin
            .as_mut()
            .ok_or_else(|| ToolError::failed("the child's input is closed"))?;
        let mut written = 0;
        let write = async {
            while written < bytes.len() {
                match writer.write(&bytes[written..]).await {
                    Ok(0) => return Err(std::io::ErrorKind::WriteZero.into()),
                    Ok(count) => written += count,
                    Err(error) => return Err(error),
                }
            }
            writer.flush().await
        };
        let interrupted = tokio::select! {
            biased;
            result = write => {
                return result.map_err(|error| ToolError::failed(format!("write to child: {error}")));
            }
            () = until(deadline) => ToolError::limit("write to child timed out"),
            () = cancellation.cancelled() => ToolError::cancelled("write to child cancelled"),
        };
        if written > 0 {
            // Anything sent after half a line would be misread.
            *stdin = None;
        }
        Err(interrupted)
    }

    /// The next stdout line, or `None` once the child closed its output.
    pub(crate) async fn recv(&self) -> Option<LiveLine> {
        self.lines.lock().await.recv().await
    }

    /// The end of the child's stderr.
    pub(crate) async fn stderr_tail(&self) -> String {
        self.stderr.lock().await.text()
    }

    /// Whether the child process is still running.
    pub(crate) fn is_running(&self) -> bool {
        !self.closed.load(Ordering::Acquire) && *self.life.borrow() == Life::Running
    }

    /// The child's activity log, when it keeps one.
    pub(crate) fn activity(&self) -> Option<&RunActivity> {
        self.activity.as_ref()
    }

    /// Record that this child serves `turn` of its conversation until the
    /// returned guard drops, which records it idle between turns. A planned
    /// restart waits for a live child only while a turn runs.
    pub(crate) fn begin_turn(&self, turn: u32) -> LiveTurn<'_> {
        // Held while the record is rewritten, so the reaper, which takes the
        // guard before removing the record, never sees it written back.
        if let Some(guard) = lock(&self.guard).as_ref() {
            guard.set_turn(turn);
        }
        LiveTurn(self)
    }

    /// Record how many of the child's own background jobs still run or wait
    /// to be reported to it. Between turns, a planned restart waits for them.
    pub(crate) fn set_background_jobs(&self, jobs: usize) {
        if let Some(guard) = lock(&self.guard).as_ref() {
            guard.set_background_jobs(jobs);
        }
    }

    /// Shut the child down and wait for it: close its input, give it
    /// [`STOP_GRACE`] to exit, then kill its process group and anything
    /// still tagged with its delegation.
    pub(crate) async fn close(&self) {
        if self.closed.swap(true, Ordering::AcqRel) {
            return;
        }
        let stdin = self.stdin.lock().await.take();
        shut_down(self.pid, stdin, self.life.clone()).await;
    }
}

/// A turn a live child is serving, from [`LiveChild::begin_turn`]. However
/// the turn ends, dropping this records the child idle; a child already
/// stopped has no record left to change.
#[must_use = "the turn ends when this guard drops"]
pub(crate) struct LiveTurn<'a>(&'a LiveChild);

impl Drop for LiveTurn<'_> {
    fn drop(&mut self) {
        if let Some(guard) = lock(&self.0.guard).as_ref() {
            guard.set_idle();
        }
        if let Some(activity) = &self.0.activity {
            if self.0.is_running() {
                activity.idle();
            } else {
                activity.turn_released();
            }
        }
    }
}

impl Drop for LiveChild {
    fn drop(&mut self) {
        if self.closed.swap(true, Ordering::AcqRel) {
            return;
        }
        let stdin = self.stdin.get_mut().take();
        let pid = self.pid;
        if let Ok(runtime) = tokio::runtime::Handle::try_current() {
            runtime.spawn(shut_down(pid, stdin, self.life.clone()));
        } else {
            // No runtime to wait in, so the reaper is gone too: kill at
            // once. The guard's drop sweeps tagged leftovers.
            kill_group(pid);
            records::untrack_spawned(pid);
            drop(lock(&self.guard).take());
        }
    }
}

/// Close the child's input, give it [`STOP_GRACE`] to exit, then kill its
/// group, and wait for the reaper to finish cleaning up.
async fn shut_down(pid: u32, stdin: Option<ChildStdin>, mut life: watch::Receiver<Life>) {
    // Closing input asks the child to finish; an SCV server exits on EOF.
    drop(stdin);
    if tokio::time::timeout(STOP_GRACE, life.wait_for(|state| *state != Life::Running))
        .await
        .is_err()
    {
        kill_group(pid);
    }
    // The reaper then stops leftovers, giving tagged ones their own grace.
    let _ = tokio::time::timeout(
        STOP_GRACE * 2 + Duration::from_millis(500),
        life.wait_for(|state| *state == Life::Finished),
    )
    .await;
}

/// Own the child for its whole life: collect it the moment it exits, then
/// stop the rest of its group and forget its delegation, so an exit between
/// turns leaves neither a zombie nor a stale `scv agents ps` entry.
async fn reap(
    pid: u32,
    mut child: Child,
    guard: Arc<StdMutex<Option<DelegationGuard>>>,
    life: watch::Sender<Life>,
    activity: Option<RunActivity>,
) {
    let _ = child.wait().await;
    let _ = life.send(Life::Exited);
    let killed = lock(&guard)
        .as_ref()
        .is_some_and(DelegationGuard::was_killed);
    if let Some(activity) = activity {
        activity.run_ended(if killed { "killed" } else { "exited" });
    }
    // Descendants may outlive the leader, so the group always goes.
    if group_exists(pid) {
        kill_group(pid);
    }
    records::untrack_spawned(pid);
    let guard = lock(&guard).take();
    if let Some(guard) = guard {
        guard.finish().await;
    }
    let _ = life.send(Life::Finished);
}

/// Split `stdout` into lines of at most `max_bytes`, counting the line
/// ending, for the protocol client.
async fn read_lines(stdout: ChildStdout, sender: mpsc::Sender<LiveLine>, max_bytes: usize) {
    let mut reader = BufReader::new(stdout);
    let mut decoder = FrameDecoder::new(max_bytes, Overflow::Stop);
    loop {
        let line = match scv_client::read_frame(&mut reader, &mut decoder).await {
            Ok(Frame::Line(line)) => LiveLine::Line(line),
            Ok(Frame::TooLarge) => {
                let _ = sender.send(LiveLine::TooLong).await;
                return;
            }
            // End of output; a partial last line is not a message.
            Ok(Frame::End | Frame::Truncated(_)) | Err(_) => return,
        };
        if sender.send(line).await.is_err() {
            return;
        }
    }
}

/// Kill every process in the child's group.
fn kill_group(pid: u32) {
    if let Some(group) = ProcessGroup::new(pid) {
        group.signal(libc::SIGKILL);
    }
}

#[cfg(test)]
mod tests;