Skip to main content

scv_tools/delegate/
activity.rs

1//! Each delegated run's activity log and current state, which the
2//! workbench shows as the agent's live view.
3//!
4//! A run (one delegation record: a CLI process for one turn, or an ACP
5//! server or nested SCV for a whole conversation) writes two files under
6//! [`Layout::runs`](scv_client::Layout::runs):
7//!
8//! - `<handle>.jsonl`, append-only: one [`RunEvent`] per line (the run's
9//!   start, each turn's prompt, the agent's message lines, its tool calls as
10//!   the same short redacted lines `tool.progress` shows, approval requests
11//!   and answers, prompts steered into a turn, and each turn's end). Never
12//!   tool output. At most [`MAX_LOG_BYTES`]; past that, only turns,
13//!   approvals, notes, and the run's end are written.
14//! - `<handle>.state.json`, rewritten atomically: what the run is doing now
15//!   ([`RunState`]), on every change of state and at most every few seconds
16//!   while it reports activity.
17//!
18//! Both files are private (mode 0600, directory 0700) and written by the
19//! SCV process that owns the run, so every process of an instance (the
20//! daemon, `scv exec`, a nested SCV's parent) is visible to the workbench,
21//! including after it stopped. Bookkeeping never fails a run: a write that
22//! fails is logged and skipped.
23
24use std::{
25    fs::File,
26    io::{BufRead as _, Read as _, Seek as _, SeekFrom, Write as _},
27    path::{Path, PathBuf},
28    sync::{Arc, Mutex},
29    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
30};
31
32use scv_protocol::{AgentState, RunEvent, RunEventKind, RunLogPage};
33use serde::{Deserialize, Serialize};
34
35use crate::{
36    delegate::{
37        progress::{redact, scrub},
38        records::{ProcessIdentity, write_private_json, write_private_json_unsynced},
39    },
40    sync::lock,
41};
42
43/// The largest activity log; past it only state-changing events are kept.
44pub const MAX_LOG_BYTES: u64 = 4 * 1024 * 1024;
45/// Longest text of one event, in characters.
46const MAX_EVENT_CHARS: usize = 2000;
47/// Longest prompt kept in a `turn_started` or `steered` event, in characters.
48const MAX_PROMPT_CHARS: usize = 4000;
49/// How often activity alone rewrites the state file.
50const STATE_REFRESH: Duration = Duration::from_secs(5);
51/// Longest state file read.
52const MAX_STATE_BYTES: u64 = 64 * 1024;
53/// Events one page of a log returns at most, and their total bytes.
54pub const MAX_PAGE_EVENTS: usize = 500;
55const MAX_PAGE_BYTES: usize = 256 * 1024;
56/// A working run that has reported nothing for this long is shown as
57/// `unknown`: SCV cannot tell whether it is thinking or stuck.
58pub const QUIET_AFTER: Duration = Duration::from_secs(5 * 60);
59/// Jobs a run remembers having served, newest last.
60const MAX_RUN_JOBS: usize = 32;
61
62/// How SCV reaches a run's agent.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub(crate) enum RunTransport {
65    /// One CLI process per turn.
66    Cli,
67    /// An Agent Client Protocol server for the whole conversation.
68    Acp,
69    /// A nested `scv server --stdio` for the whole conversation.
70    Scv,
71}
72
73impl RunTransport {
74    fn as_str(self) -> &'static str {
75        match self {
76            Self::Cli => "cli",
77            Self::Acp => "acp",
78            Self::Scv => "scv",
79        }
80    }
81}
82
83/// What a run starts with.
84pub(crate) struct RunHeader {
85    pub(crate) handle: String,
86    pub(crate) agent: String,
87    pub(crate) session: String,
88    pub(crate) transport: RunTransport,
89    pub(crate) cwd: PathBuf,
90    pub(crate) conversation: Option<(String, u32)>,
91    /// The agent's output carries live events (structured events, or text
92    /// streamed as it prints); a run whose output says nothing until the
93    /// end is never shown as quiet.
94    pub(crate) streams: bool,
95}
96
97/// What a run is doing now: `<handle>.state.json`.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct RunState {
100    pub v: u32,
101    pub handle: String,
102    pub agent: String,
103    pub session: String,
104    /// `cli`, `acp`, or `scv`.
105    pub transport: String,
106    pub cwd: PathBuf,
107    /// The SCV process that owns the run.
108    pub owner: ProcessIdentity,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub conversation: Option<String>,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub turn: Option<u32>,
113    /// The background job the current or last turn serves.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub job: Option<String>,
116    /// Every background job its turns served, oldest first (at most 32): a
117    /// live conversation's run serves the turns of several.
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub jobs: Vec<String>,
120    pub state: AgentState,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub detail: Option<String>,
123    pub started_unix: u64,
124    /// When `state` began.
125    pub since_unix: u64,
126    pub last_activity_unix: u64,
127    /// The newest message or tool line, for a quiet run's explanation.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub last_line: Option<String>,
130    /// The agent's own session ID once known, so an interrupted
131    /// conversation can be resumed through the agent's own resume.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub vendor: Option<String>,
134    #[serde(default)]
135    pub steerable: bool,
136    #[serde(default)]
137    pub streams: bool,
138    /// The run's process ended; nothing more is logged.
139    #[serde(default)]
140    pub ended: bool,
141    /// When it ended, which is what its age counts from once it has.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub ended_unix: Option<u64>,
144    /// The last event's position and the log's size.
145    #[serde(default)]
146    pub seq: u64,
147    #[serde(default)]
148    pub bytes: u64,
149}
150
151/// One line of the log file: an event and the format version.
152#[derive(Serialize, Deserialize)]
153struct Line {
154    v: u32,
155    #[serde(flatten)]
156    event: RunEvent,
157}
158
159/// A run's activity writer. Clones share one log.
160#[derive(Clone)]
161pub(crate) struct RunActivity(Arc<Mutex<Writer>>);
162
163impl std::fmt::Debug for RunActivity {
164    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        formatter.write_str("RunActivity")
166    }
167}
168
169struct Writer {
170    dir: PathBuf,
171    log: Option<File>,
172    state: RunState,
173    /// Message text that has not ended its line yet.
174    pending: String,
175    truncated: bool,
176    last_state_write: Instant,
177    /// The process ended mid-turn (`exited`, `killed`, `lost`): the run's
178    /// end is logged after that turn's, so a reader that stops at the end
179    /// has seen why the turn failed.
180    ending: Option<String>,
181    /// How its last turn ended, which a run that ends between turns ends as.
182    last_turn: Option<AgentState>,
183    /// A turn has started and its end is not logged yet.
184    turn_open: bool,
185}
186
187impl RunActivity {
188    /// Start the log of a run, or `None` when its files cannot be created
189    /// (or `handle` is not a run handle).
190    pub(crate) fn open(dir: &Path, owner: ProcessIdentity, header: RunHeader) -> Option<Self> {
191        if !valid_handle(&header.handle) {
192            return None;
193        }
194        let file = match create_log(dir, &header.handle) {
195            Ok(file) => file,
196            Err(error) => {
197                tracing::debug!(handle = %header.handle, %error, "could not start a run's activity log");
198                return None;
199            }
200        };
201        let now = unix_now();
202        let (conversation, turn) = header
203            .conversation
204            .map_or((None, None), |(handle, turn)| (Some(handle), Some(turn)));
205        let activity = Self(Arc::new(Mutex::new(Writer {
206            dir: dir.to_owned(),
207            log: Some(file),
208            state: RunState {
209                v: 1,
210                handle: header.handle,
211                agent: header.agent,
212                session: header.session,
213                transport: header.transport.as_str().to_owned(),
214                cwd: header.cwd,
215                owner,
216                conversation,
217                turn,
218                job: None,
219                jobs: Vec::new(),
220                state: AgentState::Starting,
221                detail: None,
222                started_unix: now,
223                since_unix: now,
224                last_activity_unix: now,
225                last_line: None,
226                vendor: None,
227                steerable: false,
228                streams: header.streams,
229                ended: false,
230                ended_unix: None,
231                seq: 0,
232                bytes: 0,
233            },
234            pending: String::new(),
235            truncated: false,
236            last_state_write: Instant::now(),
237            ending: None,
238            last_turn: None,
239            turn_open: false,
240        })));
241        {
242            let mut writer = lock(&activity.0);
243            let text = format!(
244                "{} over {} in {}",
245                writer.state.agent,
246                writer.state.transport,
247                writer.state.cwd.display()
248            );
249            writer.event(RunEventKind::RunStarted, text, |_| {});
250            writer.save();
251        }
252        Some(activity)
253    }
254
255    /// A turn of the run's conversation began with `prompt`, for `job` when
256    /// it serves one.
257    pub(crate) fn turn_started(
258        &self,
259        conversation: Option<&str>,
260        turn: u32,
261        prompt: &str,
262        job: Option<&str>,
263    ) {
264        let mut writer = lock(&self.0);
265        writer.flush_text();
266        if let Some(conversation) = conversation {
267            writer.state.conversation = Some(conversation.to_owned());
268        }
269        writer.state.turn = Some(turn);
270        writer.state.job = job.map(str::to_owned);
271        writer.turn_open = true;
272        if let Some(job) = job
273            && !writer.state.jobs.iter().any(|served| served == job)
274        {
275            if writer.state.jobs.len() >= MAX_RUN_JOBS {
276                writer.state.jobs.remove(0);
277            }
278            writer.state.jobs.push(job.to_owned());
279        }
280        writer.event(
281            RunEventKind::TurnStarted,
282            bounded_chars(&redact(prompt), MAX_PROMPT_CHARS),
283            |event| {
284                event.turn = Some(turn);
285                event.job = job.map(str::to_owned);
286            },
287        );
288        writer.transition(AgentState::Working, None);
289    }
290
291    /// A delta of the agent's message, which may end mid-line.
292    pub(crate) fn text(&self, chunk: &str) {
293        let mut writer = lock(&self.0);
294        writer.pending.push_str(chunk);
295        while let Some(end) = writer.pending.find('\n') {
296            let line: String = writer.pending.drain(..=end).collect();
297            writer.text_line(line.trim_end_matches(['\n', '\r']));
298        }
299        if writer.pending.chars().count() > MAX_EVENT_CHARS {
300            let line = std::mem::take(&mut writer.pending);
301            writer.text_line(&line);
302        }
303        writer.active();
304    }
305
306    /// A whole message of the agent, line by line.
307    pub(crate) fn message(&self, text: &str) {
308        let mut writer = lock(&self.0);
309        writer.flush_text();
310        for line in text.lines() {
311            writer.text_line(line);
312        }
313        writer.active();
314    }
315
316    /// A tool call or command the agent started, as a progress line. Lines
317    /// are scrubbed here whoever made them: a nested SCV relays its own.
318    pub(crate) fn tool(&self, line: &str) {
319        let line = scrub(line);
320        let line = line.as_str();
321        let mut writer = lock(&self.0);
322        writer.flush_text();
323        let failed = line.ends_with(" failed") || line.starts_with("exit ");
324        let kind = if failed {
325            RunEventKind::ToolFailed
326        } else {
327            RunEventKind::Tool
328        };
329        writer.state.last_line = Some(bounded_chars(line, 200));
330        writer.event(kind, bounded_chars(line, MAX_EVENT_CHARS), |_| {});
331        writer.active();
332    }
333
334    /// The agent's current plan step.
335    pub(crate) fn plan(&self, step: &str) {
336        let mut writer = lock(&self.0);
337        writer.flush_text();
338        writer.event(
339            RunEventKind::Plan,
340            bounded_chars(&scrub(step), MAX_EVENT_CHARS),
341            |_| {},
342        );
343        writer.active();
344    }
345
346    /// The agent asked for approval; it waits for an answer. The summary
347    /// is scrubbed here, as a nested SCV's is its own tool's.
348    pub(crate) fn approval_requested(&self, risk: &str, summary: &str) {
349        let mut writer = lock(&self.0);
350        writer.flush_text();
351        let summary = bounded_chars(&scrub(summary), MAX_EVENT_CHARS);
352        writer.event(RunEventKind::ApprovalRequested, summary.clone(), |event| {
353            event.risk = Some(risk.to_owned());
354        });
355        writer.transition(
356            AgentState::AwaitingInput,
357            Some(format!(
358                "waiting for approval: {}",
359                bounded_chars(&summary, 200)
360            )),
361        );
362    }
363
364    /// The approval request was answered.
365    pub(crate) fn approval_resolved(&self, approved: bool) {
366        let mut writer = lock(&self.0);
367        let status = if approved { "approved" } else { "denied" };
368        writer.event(RunEventKind::ApprovalResolved, String::new(), |event| {
369            event.status = Some(status.to_owned());
370        });
371        writer.transition(AgentState::Working, None);
372    }
373
374    /// A later prompt went into the running turn.
375    pub(crate) fn steered(&self, prompt: &str) {
376        let mut writer = lock(&self.0);
377        writer.flush_text();
378        writer.event(
379            RunEventKind::Steered,
380            bounded_chars(&redact(prompt), MAX_PROMPT_CHARS),
381            |_| {},
382        );
383        writer.active();
384    }
385
386    /// The current turn ended with `status` (`completed`, `failed`, ...).
387    pub(crate) fn turn_ended(&self, status: &str, error: Option<&str>) {
388        let mut writer = lock(&self.0);
389        writer.flush_text();
390        let turn = writer.state.turn;
391        writer.event(
392            RunEventKind::TurnEnded,
393            error
394                .map(|error| bounded_chars(&redact(error), MAX_EVENT_CHARS))
395                .unwrap_or_default(),
396            |event| {
397                event.turn = turn;
398                event.status = Some(status.to_owned());
399            },
400        );
401        let state = match status {
402            "completed" => AgentState::Completed,
403            "declined" => AgentState::Declined,
404            "timeout" => AgentState::Timeout,
405            "cancelled" => AgentState::Cancelled,
406            _ => AgentState::Failed,
407        };
408        writer.last_turn = Some(state);
409        writer.turn_open = false;
410        writer.transition(state, error.map(|error| bounded_chars(&redact(error), 300)));
411        if let Some(how) = writer.ending.take() {
412            writer.end(&how);
413            writer.save();
414        }
415    }
416
417    /// A live child finished its turn, or the call serving it let go, and
418    /// waits for the next prompt: no turn is open.
419    pub(crate) fn idle(&self) {
420        let mut writer = lock(&self.0);
421        writer.turn_open = false;
422        writer.transition(AgentState::Idle, None);
423    }
424
425    /// The call serving a turn let go of it after the run's process ended:
426    /// if the turn's own end was never logged (the call was dropped), the
427    /// run's end is logged now.
428    pub(crate) fn turn_released(&self) {
429        let mut writer = lock(&self.0);
430        writer.turn_open = false;
431        if let Some(how) = writer.ending.take() {
432            writer.end(&how);
433            writer.save();
434        }
435    }
436
437    /// The run's process ended: `exited`, `killed`, or `lost`.
438    ///
439    /// Between turns, the run ends as its last turn did (or failed, when
440    /// killed, or before any turn ended). Mid-turn it fails, and its end is
441    /// logged after the turn's own end, which says why, or once the turn is
442    /// let go of.
443    pub(crate) fn run_ended(&self, how: &str) {
444        let mut writer = lock(&self.0);
445        if writer.state.ended || writer.ending.is_some() {
446            return;
447        }
448        writer.flush_text();
449        let state = writer.state.state;
450        if writer.turn_open {
451            writer.transition(AgentState::Failed, Some(format!("its process {how}")));
452            writer.ending = Some(how.to_owned());
453        } else if state.is_final() {
454            writer.end(how);
455            writer.save();
456        } else {
457            let ended = match writer.last_turn {
458                Some(last) if how == "exited" && state == AgentState::Idle => last,
459                _ => AgentState::Failed,
460            };
461            writer.end(how);
462            writer.transition(ended, Some(format!("its process {how}")));
463        }
464    }
465
466    /// The agent's own session ID, for resuming the conversation later.
467    pub(crate) fn set_vendor(&self, vendor: &str) {
468        let mut writer = lock(&self.0);
469        if writer.state.vendor.as_deref() != Some(vendor) {
470            writer.state.vendor = Some(vendor.to_owned());
471            writer.save();
472        }
473    }
474
475    /// Whether a running turn of this run can take a further prompt.
476    pub(crate) fn set_steerable(&self, steerable: bool) {
477        let mut writer = lock(&self.0);
478        if writer.state.steerable != steerable {
479            writer.state.steerable = steerable;
480            writer.save();
481        }
482    }
483}
484
485impl Writer {
486    /// Log the run's end: nothing is logged after it.
487    fn end(&mut self, how: &str) {
488        self.flush_text();
489        self.append(RunEventKind::RunEnded, String::new(), |event| {
490            event.status = Some(how.to_owned());
491        });
492        self.state.ended = true;
493        self.state.ended_unix = Some(unix_now());
494    }
495
496    fn text_line(&mut self, line: &str) {
497        let line = redact(line);
498        if !line.trim().is_empty() {
499            self.state.last_line = Some(bounded_chars(line.trim(), 200));
500        }
501        self.event(
502            RunEventKind::Text,
503            bounded_chars(&line, MAX_EVENT_CHARS),
504            |_| {},
505        );
506    }
507
508    fn flush_text(&mut self) {
509        if !self.pending.is_empty() {
510            let line = std::mem::take(&mut self.pending);
511            self.text_line(line.trim_end_matches(['\n', '\r']));
512        }
513    }
514
515    /// Append one event, unless the log is full and it is mere activity.
516    fn event(&mut self, kind: RunEventKind, text: String, fill: impl FnOnce(&mut RunEvent)) {
517        let keeps_going = matches!(
518            kind,
519            RunEventKind::Text | RunEventKind::Tool | RunEventKind::ToolFailed | RunEventKind::Plan
520        );
521        if self.truncated && keeps_going {
522            return;
523        }
524        if !self.truncated && self.state.bytes >= MAX_LOG_BYTES {
525            self.truncated = true;
526            self.append(RunEventKind::Truncated, String::new(), |_| {});
527            if keeps_going {
528                return;
529            }
530        }
531        self.append(kind, text, fill);
532    }
533
534    fn append(&mut self, kind: RunEventKind, text: String, fill: impl FnOnce(&mut RunEvent)) {
535        let Some(file) = self.log.as_mut() else {
536            return;
537        };
538        let mut event = RunEvent {
539            seq: self.state.seq + 1,
540            ts_ms: unix_ms(),
541            kind,
542            text: sanitize(&text),
543            turn: None,
544            status: None,
545            job: None,
546            risk: None,
547            by: None,
548        };
549        fill(&mut event);
550        let Ok(mut bytes) = serde_json::to_vec(&Line { v: 1, event }) else {
551            return;
552        };
553        bytes.push(b'\n');
554        match file.write_all(&bytes) {
555            Ok(()) => {
556                self.state.seq += 1;
557                self.state.bytes += bytes.len() as u64;
558            }
559            Err(error) => {
560                tracing::debug!(handle = %self.state.handle, %error, "could not write a run's activity");
561                // A log that cannot be written stops here rather than leave
562                // a half line for the next event.
563                self.log = None;
564            }
565        }
566    }
567
568    /// Activity was reported: refresh the state file now and then.
569    fn active(&mut self) {
570        self.state.last_activity_unix = unix_now();
571        if self.last_state_write.elapsed() >= STATE_REFRESH {
572            self.save();
573        }
574    }
575
576    fn transition(&mut self, state: AgentState, detail: Option<String>) {
577        // A run whose process ended only settles on how it ended.
578        if (self.state.ended || self.ending.is_some()) && !state.is_final() {
579            return;
580        }
581        let now = unix_now();
582        if self.state.state != state {
583            self.state.since_unix = now;
584        }
585        self.state.state = state;
586        self.state.detail = detail;
587        self.state.last_activity_unix = now;
588        self.save();
589    }
590
591    fn save(&mut self) {
592        self.last_state_write = Instant::now();
593        let name = format!("{}.state.json", self.state.handle);
594        // Rewritten on the agent's own output path, so never synced: a
595        // machine crash at worst loses the latest state, which then settles
596        // as interrupted.
597        if let Err(error) = write_private_json_unsynced(&self.dir, &name, &self.state) {
598            tracing::debug!(handle = %self.state.handle, %error, "could not save a run's state");
599        }
600    }
601}
602
603impl Drop for Writer {
604    fn drop(&mut self) {
605        // The run's last owner let go: whatever it was doing has ended.
606        if !self.state.ended {
607            let how = self.ending.take().unwrap_or_else(|| "exited".to_owned());
608            self.end(&how);
609            if !self.state.state.is_final() {
610                self.state.state = if self.state.state == AgentState::Idle {
611                    AgentState::Completed
612                } else {
613                    AgentState::Failed
614                };
615                self.state.since_unix = unix_now();
616            }
617            self.save();
618        }
619    }
620}
621
622/// Whether `handle` is a delegation handle: an agent name, `-`, and six hex
623/// digits, so it can name files.
624pub fn valid_handle(handle: &str) -> bool {
625    let Some((agent, suffix)) = handle.rsplit_once('-') else {
626        return false;
627    };
628    handle.len() <= 64
629        && !agent.is_empty()
630        && agent
631            .bytes()
632            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
633        && agent.as_bytes()[0].is_ascii_lowercase()
634        && suffix.len() == 6
635        && suffix
636            .bytes()
637            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
638}
639
640fn create_log(dir: &Path, handle: &str) -> std::io::Result<File> {
641    use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
642    std::fs::create_dir_all(dir)?;
643    std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
644    std::fs::OpenOptions::new()
645        .append(true)
646        .create_new(true)
647        .mode(0o600)
648        .custom_flags(libc::O_NOFOLLOW)
649        .open(dir.join(format!("{handle}.jsonl")))
650}
651
652/// The state of run `handle`, as its owner last saved it.
653pub fn load_state(dir: &Path, handle: &str) -> Option<RunState> {
654    if !valid_handle(handle) {
655        return None;
656    }
657    read_state(&dir.join(format!("{handle}.state.json")))
658}
659
660fn read_state(path: &Path) -> Option<RunState> {
661    let file = open_nofollow(path).ok()?;
662    let mut bytes = Vec::new();
663    file.take(MAX_STATE_BYTES).read_to_end(&mut bytes).ok()?;
664    let state: RunState = serde_json::from_slice(&bytes).ok()?;
665    // Only a state named after its own handle is trusted.
666    let stem = path.file_name()?.to_str()?.strip_suffix(".state.json")?;
667    (stem == state.handle).then_some(state)
668}
669
670fn open_nofollow(path: &Path) -> std::io::Result<File> {
671    use std::os::unix::fs::OpenOptionsExt as _;
672    std::fs::OpenOptions::new()
673        .read(true)
674        .custom_flags(libc::O_NOFOLLOW)
675        .open(path)
676}
677
678/// Every run's saved state in `dir`.
679pub fn list_states(dir: &Path) -> Vec<RunState> {
680    let Ok(entries) = std::fs::read_dir(dir) else {
681        return Vec::new();
682    };
683    entries
684        .filter_map(Result::ok)
685        .filter(|entry| {
686            entry
687                .file_name()
688                .to_str()
689                .is_some_and(|name| name.ends_with(".state.json"))
690        })
691        .filter_map(|entry| read_state(&entry.path()))
692        .collect()
693}
694
695/// Events of run `handle` after byte position `after` (0 from the start),
696/// at most `limit` of them: one page of its log.
697pub fn read_page(
698    dir: &Path,
699    handle: &str,
700    after: u64,
701    limit: usize,
702) -> std::io::Result<RunLogPage> {
703    if !valid_handle(handle) {
704        return Err(std::io::Error::new(
705            std::io::ErrorKind::InvalidInput,
706            "not a run handle",
707        ));
708    }
709    let limit = limit.clamp(1, MAX_PAGE_EVENTS);
710    let mut file = open_nofollow(&dir.join(format!("{handle}.jsonl")))?;
711    let length = file.metadata()?.len();
712    let mut position = after.min(length);
713    if position > 0 {
714        // A position that is not right after a line ending resynchronizes
715        // at the next one.
716        file.seek(SeekFrom::Start(position - 1))?;
717        let mut previous = [0u8; 1];
718        file.read_exact(&mut previous)?;
719        if previous[0] != b'\n' {
720            let mut skipped = Vec::new();
721            let mut reader = std::io::BufReader::new(&mut file);
722            position += reader.read_until(b'\n', &mut skipped)? as u64;
723        }
724    }
725    file.seek(SeekFrom::Start(position))?;
726    let mut reader = std::io::BufReader::new(file);
727    let mut events = Vec::new();
728    let mut bytes = 0usize;
729    let mut more = false;
730    let mut ended = false;
731    loop {
732        let mut line = Vec::new();
733        let read = reader.read_until(b'\n', &mut line)?;
734        if read == 0 || line.last() != Some(&b'\n') {
735            // End of the log, or a line its writer has not finished.
736            break;
737        }
738        if read > MAX_PAGE_BYTES {
739            // No page could hold it: skipped, so readers move on.
740            position += read as u64;
741            continue;
742        }
743        if events.len() >= limit || bytes + read > MAX_PAGE_BYTES {
744            more = true;
745            break;
746        }
747        position += read as u64;
748        bytes += read;
749        if let Ok(Line { event, .. }) = serde_json::from_slice::<Line>(&line) {
750            ended |= event.kind == RunEventKind::RunEnded;
751            events.push(event);
752        }
753    }
754    if !more && !ended {
755        ended = load_state(dir, handle).is_some_and(|state| state.ended)
756            && position >= reader.get_ref().metadata().map_or(0, |meta| meta.len());
757    }
758    Ok(RunLogPage {
759        handle: handle.to_owned(),
760        events,
761        next: position,
762        ended,
763        more,
764    })
765}
766
767/// Close the logs of runs whose owning SCV process stopped without ending
768/// them (a crash or a kill): note it, end the log, and mark the run
769/// interrupted. Returns the handles settled.
770pub fn settle_orphans(dir: &Path) -> Vec<String> {
771    let mut settled = Vec::new();
772    for mut state in list_states(dir) {
773        if state.ended || state.owner.is_alive() {
774            continue;
775        }
776        let path = dir.join(format!("{}.jsonl", state.handle));
777        let appended = append_existing(
778            &path,
779            &mut state,
780            &[
781                (
782                    RunEventKind::Note,
783                    "interrupted: the SCV process that ran it stopped".into(),
784                    None,
785                ),
786                (RunEventKind::RunEnded, String::new(), Some("lost".into())),
787            ],
788        );
789        if appended.is_err() {
790            tracing::debug!(handle = %state.handle, "could not end an interrupted run's log");
791        }
792        state.ended = true;
793        state.ended_unix = Some(unix_now());
794        if !state.state.is_final() {
795            state.state = AgentState::Interrupted;
796            state.detail = Some("the SCV process that ran it stopped".into());
797            state.since_unix = unix_now();
798        }
799        if write_private_json(dir, &format!("{}.state.json", state.handle), &state).is_ok() {
800            settled.push(state.handle);
801        }
802    }
803    settled
804}
805
806fn append_existing(
807    path: &Path,
808    state: &mut RunState,
809    events: &[(RunEventKind, String, Option<String>)],
810) -> std::io::Result<()> {
811    use std::os::unix::fs::OpenOptionsExt as _;
812    let mut file = std::fs::OpenOptions::new()
813        .read(true)
814        .append(true)
815        .custom_flags(libc::O_NOFOLLOW)
816        .open(path)?;
817    // A line its writer left torn is ended first, or what follows would
818    // join it and be lost with it.
819    if file.metadata()?.len() > 0 {
820        let mut last = [0u8];
821        file.seek(SeekFrom::End(-1))?;
822        file.read_exact(&mut last)?;
823        if last != *b"\n" {
824            file.write_all(b"\n")?;
825            state.bytes += 1;
826        }
827    }
828    for (kind, text, status) in events {
829        let event = RunEvent {
830            seq: state.seq + 1,
831            ts_ms: unix_ms(),
832            kind: *kind,
833            text: text.clone(),
834            turn: None,
835            status: status.clone(),
836            job: None,
837            risk: None,
838            by: None,
839        };
840        let mut bytes = serde_json::to_vec(&Line { v: 1, event }).map_err(std::io::Error::other)?;
841        bytes.push(b'\n');
842        file.write_all(&bytes)?;
843        state.seq += 1;
844        state.bytes += bytes.len() as u64;
845    }
846    Ok(())
847}
848
849/// Remove the logs of runs that ended more than `keep` ago (a run settled
850/// as interrupted counts from then), then the oldest ended ones beyond
851/// `max_runs`. Runs still going are never removed.
852pub fn prune(dir: &Path, keep: Duration, max_runs: usize) -> usize {
853    let now = unix_now();
854    let mut ended: Vec<RunState> = list_states(dir)
855        .into_iter()
856        .filter(|state| state.ended)
857        .collect();
858    let ended_at = |state: &RunState| state.ended_unix.unwrap_or(state.last_activity_unix);
859    ended.sort_by_key(|state| std::cmp::Reverse(ended_at(state)));
860    let mut removed = 0;
861    for (index, state) in ended.iter().enumerate() {
862        let old = now.saturating_sub(ended_at(state)) > keep.as_secs();
863        if old || index >= max_runs {
864            let _ = std::fs::remove_file(dir.join(format!("{}.jsonl", state.handle)));
865            let _ = std::fs::remove_file(dir.join(format!("{}.state.json", state.handle)));
866            removed += 1;
867        }
868    }
869    removed
870}
871
872/// What a run's saved state means now: the state, and SCV's words for it.
873/// A run whose owner stopped without ending it is interrupted; one that has
874/// reported nothing for [`QUIET_AFTER`] while working is `unknown`.
875pub fn current(state: &RunState, now_unix: u64) -> (AgentState, Option<String>) {
876    if !state.ended && !state.owner.is_alive() {
877        return (
878            AgentState::Interrupted,
879            Some("the SCV process that ran it stopped".into()),
880        );
881    }
882    let quiet = now_unix.saturating_sub(state.last_activity_unix);
883    match state.state {
884        AgentState::Working if quiet >= QUIET_AFTER.as_secs() && state.streams => (
885            AgentState::Unknown,
886            Some(format!(
887                "no activity for {} min{}",
888                quiet / 60,
889                state
890                    .last_line
891                    .as_deref()
892                    .map(|line| format!("; last: {line}"))
893                    .unwrap_or_default()
894            )),
895        ),
896        AgentState::Working if !state.streams => (
897            AgentState::Working,
898            Some("this agent prints its answer only when it finishes".into()),
899        ),
900        AgentState::Idle => (AgentState::Idle, Some("waiting for the next prompt".into())),
901        other => (other, state.detail.clone()),
902    }
903}
904
905/// `text` without control characters other than tabs, which become spaces.
906fn sanitize(text: &str) -> String {
907    text.chars()
908        .map(|character| {
909            if character == '\t' {
910                ' '
911            } else if character.is_control() {
912                '\u{fffd}'
913            } else {
914                character
915            }
916        })
917        .collect()
918}
919
920fn bounded_chars(text: &str, max: usize) -> String {
921    if text.chars().count() <= max {
922        return text.to_owned();
923    }
924    let mut cut: String = text.chars().take(max.saturating_sub(1)).collect();
925    cut.push('…');
926    cut
927}
928
929fn unix_now() -> u64 {
930    SystemTime::now()
931        .duration_since(UNIX_EPOCH)
932        .map_or(0, |elapsed| elapsed.as_secs())
933}
934
935fn unix_ms() -> u64 {
936    SystemTime::now()
937        .duration_since(UNIX_EPOCH)
938        .map_or(0, |elapsed| {
939            u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
940        })
941}
942
943#[cfg(test)]
944mod tests;