Skip to main content

scv_tools/
workbench.rs

1//! The daemon's workbench: one place that knows every delegated agent, background
2//! job, and session of the instance, and lets the owner act on them.
3//!
4//! What it shows comes from the durable records every SCV process of the
5//! instance writes (the job ledger, each run's activity log and state, and
6//! the delegation records), so jobs and runs of `scv exec` servers, and of
7//! processes that stopped, are listed too. What it does needs the live
8//! objects, so only sessions of this process can be acted on: each
9//! registers with [`Workbench::register_session`], and its `agent` tool
10//! attaches its job store when the registry builds it.
11//!
12//! The owner acts on:
13//!
14//! - a conversation: [`Workbench::send`] gives it the owner's prompt as a
15//!   new turn, queued behind a running one, or steered into it;
16//! - a job: [`Workbench::cancel`] stops it, [`Workbench::resume`] runs an
17//!   interrupted one again, [`Workbench::dismiss`] forgets a finished one;
18//! - an approval request a background job relayed while the owner watches
19//!   ([`Workbench::watch`]): it waits for [`Workbench::approve`] up to
20//!   [`ATTENDED_WAIT`], then gets the session's unattended answer.
21
22use std::{
23    collections::{BTreeMap, HashMap, HashSet},
24    path::{Path, PathBuf},
25    sync::{Arc, Mutex, Weak},
26    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
27};
28
29use async_trait::async_trait;
30use scv_client::Layout;
31use scv_core::{AgentError, ApprovalGate, ApprovalRequest};
32use scv_protocol::{
33    AgentState, RunLogPage, SendMode, WorkbenchApproval, WorkbenchJob, WorkbenchRun,
34    WorkbenchSession, WorkbenchSnapshot,
35};
36use serde_json::{Value, json};
37use tokio::sync::oneshot;
38use tokio_util::sync::CancellationToken;
39
40use crate::{
41    delegate::{
42        activity::{self, RunState},
43        background::{
44            BackgroundCapable, BackgroundJobs, JobMeta, LiveJob, OwnerCall,
45            ledger::{self, JobRecord},
46        },
47        records::{DelegationRegistry, ProcessIdentity},
48    },
49    sync::lock,
50};
51
52/// How long a background job's approval request waits for the owner while
53/// the owner watches, before it gets the session's unattended answer.
54pub const ATTENDED_WAIT: Duration = Duration::from_secs(120);
55/// How long one `watch` keeps the owner counted as watching.
56pub const WATCH_TTL: Duration = Duration::from_secs(15);
57/// Jobs and runs a snapshot lists at most: every unfinished one, then the
58/// newest finished ones.
59const MAX_LISTED: usize = 200;
60/// Finished jobs and ended runs are kept this long.
61pub const KEEP: Duration = Duration::from_secs(7 * 24 * 3600);
62/// Ended runs kept at most.
63pub const MAX_RUNS: usize = 500;
64
65/// The daemon's workbench.
66pub struct Workbench {
67    runs: PathBuf,
68    jobs: PathBuf,
69    worktrees: PathBuf,
70    delegations: Arc<DelegationRegistry>,
71    inner: Mutex<Inner>,
72    watched_until: Mutex<Option<Instant>>,
73    /// Jobs being resumed now, `<session>/<job>`: each is resumed once.
74    resuming: Mutex<HashSet<String>>,
75}
76
77impl std::fmt::Debug for Workbench {
78    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        formatter.debug_struct("Workbench").finish_non_exhaustive()
80    }
81}
82
83#[derive(Default)]
84struct Inner {
85    sessions: HashMap<String, Slot>,
86    approvals: BTreeMap<String, Pending>,
87    next: u64,
88}
89
90/// A live session of this process.
91struct Slot {
92    token: u64,
93    label: String,
94    workspace: PathBuf,
95    state: AgentState,
96    detail: Option<String>,
97    agent: Option<Weak<BackgroundCapable>>,
98    jobs: Option<Weak<BackgroundJobs>>,
99}
100
101struct Pending {
102    info: WorkbenchApproval,
103    answer: Option<oneshot::Sender<bool>>,
104}
105
106/// A session's place in the workbench while it lives; dropping it removes
107/// the session.
108pub struct SessionHandle {
109    workbench: Weak<Workbench>,
110    id: String,
111    token: u64,
112}
113
114impl std::fmt::Debug for SessionHandle {
115    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        formatter
117            .debug_struct("SessionHandle")
118            .field("id", &self.id)
119            .finish()
120    }
121}
122
123impl SessionHandle {
124    /// What the session is doing: a turn runs, an approval waits for its
125    /// client, or it is idle.
126    pub fn set_state(&self, state: AgentState, detail: Option<String>) {
127        let Some(workbench) = self.workbench.upgrade() else {
128            return;
129        };
130        if let Some(slot) = lock(&workbench.inner).sessions.get_mut(&self.id)
131            && slot.token == self.token
132        {
133            slot.state = state;
134            slot.detail = detail;
135        }
136    }
137}
138
139impl Drop for SessionHandle {
140    fn drop(&mut self) {
141        let Some(workbench) = self.workbench.upgrade() else {
142            return;
143        };
144        let mut inner = lock(&workbench.inner);
145        if inner
146            .sessions
147            .get(&self.id)
148            .is_some_and(|slot| slot.token == self.token)
149        {
150            inner.sessions.remove(&self.id);
151        }
152    }
153}
154
155/// What [`Workbench::send`] did with the owner's prompt.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub enum Sent {
158    /// A background job of the session runs it: `<session>/<job>`.
159    Job(String),
160    /// It went into the conversation's running turn.
161    Steered,
162}
163
164/// What a maintenance pass did.
165#[derive(Debug, Clone, Default, PartialEq, Eq)]
166pub struct Maintenance {
167    /// Jobs found interrupted: their SCV process stopped.
168    pub interrupted_jobs: Vec<String>,
169    /// Runs found interrupted.
170    pub interrupted_runs: Vec<String>,
171    /// Old records removed.
172    pub pruned: usize,
173}
174
175impl Workbench {
176    /// The workbench of the instance at `layout`, whose delegated runs
177    /// `delegations` records.
178    pub fn new(layout: &Layout, delegations: Arc<DelegationRegistry>) -> Arc<Self> {
179        Arc::new(Self {
180            runs: layout.runs(),
181            jobs: layout.jobs(),
182            worktrees: layout.worktrees(),
183            delegations,
184            inner: Mutex::default(),
185            watched_until: Mutex::new(None),
186            resuming: Mutex::default(),
187        })
188    }
189
190    /// Where the job ledger is.
191    pub fn jobs_dir(&self) -> &Path {
192        &self.jobs
193    }
194
195    /// Register a live session; `label` says who it serves.
196    pub fn register_session(
197        self: &Arc<Self>,
198        id: &str,
199        label: &str,
200        workspace: &Path,
201    ) -> SessionHandle {
202        let mut inner = lock(&self.inner);
203        inner.next += 1;
204        let token = inner.next;
205        inner.sessions.insert(
206            id.to_owned(),
207            Slot {
208                token,
209                label: label.to_owned(),
210                workspace: workspace.to_owned(),
211                state: AgentState::Idle,
212                detail: None,
213                agent: None,
214                jobs: None,
215            },
216        );
217        SessionHandle {
218            workbench: Arc::downgrade(self),
219            id: id.to_owned(),
220            token,
221        }
222    }
223
224    /// The `agent` tool and job store of live session `session`.
225    pub(crate) fn attach(
226        &self,
227        session: &str,
228        agent: Weak<BackgroundCapable>,
229        jobs: Weak<BackgroundJobs>,
230    ) {
231        if let Some(slot) = lock(&self.inner).sessions.get_mut(session) {
232            slot.agent = Some(agent);
233            slot.jobs = Some(jobs);
234        }
235    }
236
237    /// The owner is watching: background approval requests wait for an
238    /// answer for the next [`WATCH_TTL`].
239    pub fn watch(&self) {
240        *lock(&self.watched_until) = Some(Instant::now() + WATCH_TTL);
241    }
242
243    fn watched(&self) -> bool {
244        lock(&self.watched_until).is_some_and(|until| Instant::now() < until)
245    }
246
247    /// The approval gate for background jobs of `session`: `unattended`
248    /// decides, except that a request it would deny waits up to
249    /// [`ATTENDED_WAIT`] for the owner while the owner watches. It never
250    /// denies what `unattended` allows.
251    pub fn attended_gate(
252        self: &Arc<Self>,
253        session: &str,
254        unattended: Arc<dyn ApprovalGate>,
255    ) -> Arc<dyn ApprovalGate> {
256        Arc::new(AttendedGate {
257            workbench: Arc::downgrade(self),
258            session: session.to_owned(),
259            unattended,
260        })
261    }
262
263    /// Hold `request` of a job of `session` for the owner's answer.
264    async fn ask(
265        &self,
266        session: &str,
267        request: ApprovalRequest,
268        cancellation: &CancellationToken,
269    ) -> Result<bool, AgentError> {
270        let (sender, receiver) = oneshot::channel();
271        let now = unix_now();
272        let id = format!("ap-{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
273        lock(&self.inner).approvals.insert(
274            id.clone(),
275            Pending {
276                info: WorkbenchApproval {
277                    id: id.clone(),
278                    session: session.to_owned(),
279                    job: request.call_id.clone(),
280                    name: request.name.clone(),
281                    risk: request.risk.as_str().to_owned(),
282                    cwd: request.cwd.display().to_string(),
283                    summary: request.summary.clone(),
284                    since_unix_seconds: now,
285                    expires_unix_seconds: now + ATTENDED_WAIT.as_secs(),
286                },
287                answer: Some(sender),
288            },
289        );
290        // An answer that lands as the deadline fires is still the answer.
291        // Closing the receiver and taking anything already sent stops the
292        // waiter from reporting a denial after `approve` has sent `true`.
293        let mut receiver = receiver;
294        let answer = tokio::select! {
295            biased;
296            answer = &mut receiver => Ok(answer.unwrap_or(false)),
297            () = tokio::time::sleep(ATTENDED_WAIT) => {
298                receiver.close();
299                Ok(receiver.try_recv().unwrap_or(false))
300            }
301            () = cancellation.cancelled() => {
302                receiver.close();
303                match receiver.try_recv() {
304                    Ok(approved) => Ok(approved),
305                    Err(_) => Err(AgentError::Cancelled),
306                }
307            }
308        };
309        lock(&self.inner).approvals.remove(&id);
310        answer
311    }
312
313    /// Answer approval request `id`.
314    pub fn approve(&self, id: &str, approved: bool) -> Result<String, String> {
315        let pending = lock(&self.inner).approvals.get_mut(id).and_then(|pending| {
316            pending
317                .answer
318                .take()
319                .map(|answer| (answer, pending.info.clone()))
320        });
321        let Some((answer, info)) = pending else {
322            return Err(format!(
323                "no approval request {id:?} waits; it may have been answered or timed out"
324            ));
325        };
326        if answer.send(approved).is_err() {
327            return Err(format!("{} no longer waits for this answer", info.job));
328        }
329        Ok(format!(
330            "{} {}: {}",
331            if approved { "Approved" } else { "Denied" },
332            info.job,
333            bounded(&info.summary, 120)
334        ))
335    }
336
337    /// Everything the workbench shows now. `restorable` is left for the
338    /// server, which keeps session queues.
339    pub fn snapshot(&self) -> WorkbenchSnapshot {
340        let now = unix_now();
341        let live_handles = self.live_handles();
342        let (slots, approvals): (Vec<_>, Vec<_>) = {
343            let inner = lock(&self.inner);
344            (
345                inner
346                    .sessions
347                    .iter()
348                    .map(|(id, slot)| SlotView {
349                        id: id.clone(),
350                        label: slot.label.clone(),
351                        workspace: slot.workspace.clone(),
352                        state: slot.state,
353                        detail: slot.detail.clone(),
354                        agent: slot.agent.as_ref().and_then(Weak::upgrade),
355                        jobs: slot.jobs.as_ref().and_then(Weak::upgrade),
356                    })
357                    .collect(),
358                inner
359                    .approvals
360                    .values()
361                    .map(|pending| pending.info.clone())
362                    .collect(),
363            )
364        };
365        let live_jobs: HashMap<(String, String), LiveJob> = slots
366            .iter()
367            .filter_map(|slot| {
368                slot.jobs
369                    .as_ref()
370                    .map(|jobs| (slot.id.clone(), jobs.live()))
371            })
372            .flat_map(|(session, jobs)| {
373                jobs.into_iter()
374                    .map(move |job| ((session.clone(), job.id.clone()), job))
375            })
376            .collect();
377        let live_sessions: HashSet<&str> = slots.iter().map(|slot| slot.id.as_str()).collect();
378        let awaiting: HashMap<(String, String), &WorkbenchApproval> = approvals
379            .iter()
380            .map(|approval| ((approval.session.clone(), approval.job.clone()), approval))
381            .collect();
382
383        // A conversation handle is shown only while the live session that
384        // made it still has it, so the owner can send it a prompt: one an
385        // earlier daemon, or an earlier session under an ID that came back
386        // (a workbench session), left names nothing now. Such a session
387        // numbers its conversations after the ones its records name, so a
388        // handle never comes to mean another conversation.
389        let me = ProcessIdentity::current();
390        let held = |owner: &ProcessIdentity, session: &str, handle: &str| {
391            me.as_ref() == Some(owner)
392                && slots.iter().any(|slot| {
393                    slot.id == session
394                        && slot.agent.as_ref().is_some_and(|agent| agent.knows(handle))
395                })
396        };
397        let mut states = activity::list_states(&self.runs);
398        states
399            .sort_by_key(|state| std::cmp::Reverse((state.last_activity_unix, state.started_unix)));
400        let mut runs: Vec<WorkbenchRun> = Vec::new();
401        let mut job_runs: HashMap<(String, String), &RunState> = HashMap::new();
402        for state in &states {
403            for job in state.jobs.iter().chain(&state.job) {
404                job_runs
405                    .entry((state.session.clone(), job.clone()))
406                    .or_insert(state);
407            }
408        }
409        let mut ended_listed = 0;
410        for state in &states {
411            if state.ended {
412                if ended_listed >= MAX_LISTED {
413                    continue;
414                }
415                ended_listed += 1;
416            }
417            let (mut current, mut detail) = activity::current(state, now);
418            if let Some(job) = &state.job
419                && let Some(approval) = awaiting.get(&(state.session.clone(), job.clone()))
420                && !state.ended
421            {
422                current = AgentState::AwaitingInput;
423                detail = Some(format!(
424                    "waiting for your approval: {}",
425                    bounded(&approval.summary, 200)
426                ));
427            }
428            runs.push(WorkbenchRun {
429                handle: state.handle.clone(),
430                agent: state.agent.clone(),
431                session: state.session.clone(),
432                transport: state.transport.clone(),
433                conversation: state
434                    .conversation
435                    .clone()
436                    .filter(|handle| held(&state.owner, &state.session, handle)),
437                turn: state.turn,
438                job: state.job.clone(),
439                jobs: state.jobs.clone(),
440                cwd: state.cwd.display().to_string(),
441                state: current,
442                detail,
443                started_unix_seconds: state.started_unix,
444                last_activity_unix_seconds: state.last_activity_unix,
445                live: !state.ended && live_handles.contains(&state.handle),
446                steerable: state.steerable,
447            });
448        }
449
450        let all = ledger::list_all(&self.jobs);
451        let mut records: Vec<&JobRecord> = all.iter().filter(|record| !record.dismissed).collect();
452        records.sort_by_key(|record| std::cmp::Reverse((record.created_unix, record.job.clone())));
453        let mut jobs = Vec::new();
454        let mut finished_listed = 0;
455        // What each job was resumed as, by the record of the job resuming
456        // it too, which is written first: a dismissed one's included, or
457        // the job would be resumable again.
458        let successors: HashMap<&str, String> = all
459            .iter()
460            .filter_map(|record| {
461                let from = record.resumed_from.as_deref()?;
462                Some((from, format!("{}/{}", record.session, record.job)))
463            })
464            .collect();
465        for &record in &records {
466            let key = (record.session.clone(), record.job.clone());
467            let resumed_as = record.resumed_as.clone().or_else(|| {
468                successors
469                    .get(format!("{}/{}", record.session, record.job).as_str())
470                    .cloned()
471            });
472            let live = live_jobs.get(&key);
473            let run = job_runs.get(&key).copied();
474            let (state, detail) = job_state(
475                record,
476                live,
477                run,
478                awaiting.get(&key).copied(),
479                live_sessions.contains(record.session.as_str()),
480                now,
481            );
482            // An interrupted job the owner can still resume stays listed
483            // after the cap of other finished jobs. Counting it as finished
484            // would hide it once newer completions filled the snapshot.
485            let resumable_interrupted =
486                state == AgentState::Interrupted && resumed_as.is_none() && !record.reviewed;
487            if state.is_final() && !resumable_interrupted {
488                if finished_listed >= MAX_LISTED {
489                    continue;
490                }
491                finished_listed += 1;
492            }
493            let cwd = run
494                .map(|run| run.cwd.display().to_string())
495                .or_else(|| {
496                    record
497                        .worktree
498                        .as_ref()
499                        .map(|worktree| worktree.path.clone())
500                })
501                .unwrap_or_else(|| {
502                    let relative = record.arguments.get("cwd").and_then(Value::as_str);
503                    relative.map_or_else(
504                        || record.workspace.display().to_string(),
505                        |cwd| record.workspace.join(cwd).display().to_string(),
506                    )
507                });
508            jobs.push(WorkbenchJob {
509                session: record.session.clone(),
510                job: record.job.clone(),
511                agent: record.agent.clone(),
512                task: record.task.clone(),
513                workspace: record.workspace.display().to_string(),
514                cwd,
515                state,
516                detail,
517                started_by: record.started_by,
518                conversation: record
519                    .conversation
520                    .clone()
521                    .or_else(|| run.and_then(|run| run.conversation.clone()))
522                    .or_else(|| {
523                        record
524                            .arguments
525                            .get("session")
526                            .and_then(Value::as_str)
527                            .map(str::to_owned)
528                    })
529                    .filter(|handle| held(&record.owner, &record.session, handle)),
530                run: run.map(|run| run.handle.clone()),
531                worktree: record.worktree.clone(),
532                reviewed: record.reviewed,
533                resumable: state == AgentState::Interrupted
534                    && !record.reviewed
535                    && resumed_as.is_none(),
536                resumed_as,
537                created_unix_seconds: record.created_unix,
538                updated_unix_seconds: record.updated_unix,
539            });
540        }
541
542        let mut sessions: Vec<WorkbenchSession> = slots
543            .iter()
544            .map(|slot| WorkbenchSession {
545                id: slot.id.clone(),
546                label: slot.label.clone(),
547                workspace: slot.workspace.display().to_string(),
548                live: true,
549                state: slot.state,
550                detail: slot.detail.clone(),
551            })
552            .collect();
553        let mut seen: HashSet<String> = sessions.iter().map(|session| session.id.clone()).collect();
554        for &record in &records {
555            if seen.insert(record.session.clone()) {
556                let alive = record.owner.is_alive() && record.owner.pid != std::process::id();
557                sessions.push(WorkbenchSession {
558                    id: record.session.clone(),
559                    label: record.label.clone(),
560                    workspace: record.workspace.display().to_string(),
561                    live: alive,
562                    state: AgentState::Unknown,
563                    detail: Some(if alive {
564                        format!("runs in SCV process {}", record.owner.pid)
565                    } else {
566                        "ended".to_owned()
567                    }),
568                });
569            }
570        }
571        sessions.sort_by(|a, b| b.live.cmp(&a.live).then_with(|| a.id.cmp(&b.id)));
572        WorkbenchSnapshot {
573            generated_unix_seconds: now,
574            sessions,
575            jobs,
576            runs,
577            approvals,
578            restorable: Vec::new(),
579        }
580    }
581
582    /// The runs whose processes still run, in any SCV process of the
583    /// instance.
584    fn live_handles(&self) -> HashSet<String> {
585        self.delegations
586            .list(true)
587            .into_iter()
588            .filter(|entry| entry.processes > 0)
589            .map(|entry| entry.record.handle)
590            .collect()
591    }
592
593    /// Refuse to remove the worktree holding `path` while anything still
594    /// works in it ([`Self::worktree_users`]).
595    pub fn check_worktree_unused(&self, path: &Path) -> Result<(), String> {
596        let users = self.worktree_users(path);
597        if users.is_empty() {
598            return Ok(());
599        }
600        Err(format!(
601            "{} still work in {}; stop them before removing it",
602            users.join(", "),
603            path.display()
604        ))
605    }
606
607    /// What still runs in the SCV worktree holding `path` (its top, or any
608    /// directory in it): jobs not yet finished that recorded it, and
609    /// delegated runs whose processes work inside it.
610    pub fn worktree_users(&self, path: &Path) -> Vec<String> {
611        let path = crate::delegate::worktree::worktree_path(&self.worktrees, path)
612            .or_else(|| std::fs::canonicalize(path).ok())
613            .unwrap_or_else(|| path.to_owned());
614        let mut users: Vec<String> = ledger::list(&self.jobs)
615            .into_iter()
616            .filter(|record| {
617                !record.state.is_final()
618                    && record.owner.is_alive()
619                    && record
620                        .worktree
621                        .as_ref()
622                        .is_some_and(|worktree| Path::new(&worktree.path) == path)
623            })
624            .map(|record| format!("{}/{}", record.session, record.job))
625            .collect();
626        users.extend(
627            self.delegations
628                .list(true)
629                .into_iter()
630                .filter(|entry| entry.processes > 0 && entry.record.cwd.starts_with(&path))
631                .map(|entry| entry.record.handle),
632        );
633        users
634    }
635
636    /// One page of run `handle`'s activity log after byte position `after`.
637    pub fn run_log(&self, handle: &str, after: u64, limit: usize) -> Result<RunLogPage, String> {
638        activity::read_page(&self.runs, handle, after, limit).map_err(|error| {
639            if error.kind() == std::io::ErrorKind::NotFound {
640                format!("no activity log for run {handle:?}")
641            } else {
642                format!("cannot read run {handle:?}: {error}")
643            }
644        })
645    }
646
647    /// The `agent` tool of live session `session`.
648    fn agent(&self, session: &str) -> Result<(Arc<BackgroundCapable>, PathBuf), String> {
649        let inner = lock(&self.inner);
650        let slot = inner.sessions.get(session).ok_or_else(|| {
651            format!("session {session} does not run in this daemon (it ended, or another SCV process runs it)")
652        })?;
653        let agent = slot
654            .agent
655            .as_ref()
656            .and_then(Weak::upgrade)
657            .ok_or_else(|| format!("session {session} runs no background agent jobs"))?;
658        Ok((agent, slot.workspace.clone()))
659    }
660
661    /// Give conversation `conversation` of live session `session` the
662    /// owner's `prompt`.
663    pub async fn send(
664        &self,
665        session: &str,
666        conversation: &str,
667        prompt: &str,
668        mode: SendMode,
669    ) -> Result<Sent, String> {
670        let (agent, workspace) = self.agent(session)?;
671        let call = agent
672            .owner_call(
673                json!({"session": conversation, "prompt": prompt}),
674                workspace,
675                mode,
676                JobMeta::default(),
677            )
678            .await
679            .map_err(|error| error.message)?;
680        Ok(match call {
681            OwnerCall::Job(started) => Sent::Job(job_name(session, &started)),
682            OwnerCall::Steered => Sent::Steered,
683        })
684    }
685
686    /// Start the owner's own job in live session `session` with the `agent`
687    /// call `arguments`. Returns `<session>/<job>`.
688    pub async fn start(&self, session: &str, arguments: Value) -> Result<String, String> {
689        self.start_with(session, arguments, JobMeta::default())
690            .await
691    }
692
693    /// [`Self::start`], with what the job's record holds from the start.
694    async fn start_with(
695        &self,
696        session: &str,
697        arguments: Value,
698        meta: JobMeta,
699    ) -> Result<String, String> {
700        let (agent, workspace) = self.agent(session)?;
701        match agent
702            .owner_call(arguments, workspace, SendMode::Queue, meta)
703            .await
704            .map_err(|error| error.message)?
705        {
706            OwnerCall::Job(started) => Ok(job_name(session, &started)),
707            OwnerCall::Steered => Err("the prompt went into a running turn".into()),
708        }
709    }
710
711    /// Run interrupted job `job` of `session` again as the owner's job in
712    /// live session `host`, which serves the job's workspace. It continues
713    /// the agent's own conversation when the agent saved one and can resume
714    /// it; otherwise it starts over, told that an earlier attempt may have
715    /// left changes. A queued follow-up has no run of its own: it uses the
716    /// conversation's directory and worktree, continues that agent session
717    /// only after the interrupted job the run served has been resumed (and
718    /// then behind that conversation), and otherwise starts in a new
719    /// worktree of the checkout that job recorded. Each job is resumed
720    /// once, and not while an agent process of its run still goes. Returns
721    /// `<host>/<new job>`.
722    pub async fn resume(&self, host: &str, session: &str, job: &str) -> Result<String, String> {
723        let key = format!("{session}/{job}");
724        if !lock(&self.resuming).insert(key.clone()) {
725            return Err(format!("{job} is being resumed already"));
726        }
727        // Released however this ends, a dropped request included.
728        let _claim = Claim {
729            claims: &self.resuming,
730            key: &key,
731        };
732        self.resume_once(host, session, job, &key).await
733    }
734
735    async fn resume_once(
736        &self,
737        host: &str,
738        session: &str,
739        job: &str,
740        key: &str,
741    ) -> Result<String, String> {
742        let record = ledger::load(&self.jobs, session, job)
743            .ok_or_else(|| format!("no record of {session}/{job}"))?;
744        let session_live = lock(&self.inner).sessions.contains_key(session);
745        let interrupted = record.state == AgentState::Interrupted
746            || (!record.state.is_final()
747                && (!record.owner.is_alive()
748                    || (record.owner.pid == std::process::id() && !session_live)));
749        if !interrupted {
750            return Err(format!(
751                "{job} was not interrupted ({}); only an interrupted job can be resumed",
752                record.state
753            ));
754        }
755        if record.reviewed {
756            return Err(
757                "a reviewed job is not resumed: its journal is its record. Start a new \
758                 reviewed call whose brief restates the open findings and anything already \
759                 landed"
760                    .into(),
761            );
762        }
763        if let Some(resumed) = &record.resumed_as {
764            return Err(format!("{job} was already resumed as {resumed}"));
765        }
766        let (agent, _) = self.agent(host)?;
767        // Reading every record, run state, and the process table takes a
768        // while.
769        let (successor, run, live, blocking) = {
770            let (jobs, runs) = (self.jobs.clone(), self.runs.clone());
771            let delegations = Arc::clone(&self.delegations);
772            let continued = record
773                .arguments
774                .get("session")
775                .and_then(Value::as_str)
776                .map(str::to_owned);
777            let (session, job, key) = (session.to_owned(), job.to_owned(), key.to_owned());
778            tokio::task::spawn_blocking(move || {
779                // A job that names it as what it resumes settles it, should
780                // SCV have stopped before this record said so, even once
781                // the owner dismissed that job.
782                let successor = ledger::list_all(&jobs)
783                    .into_iter()
784                    .find(|record| record.resumed_from.as_deref() == Some(key.as_str()))
785                    .map(|record| format!("{}/{}", record.session, record.job));
786                let states = activity::list_states(&runs);
787                let served = |state: &activity::RunState| {
788                    state.session == session
789                        && (state.job.as_deref() == Some(job.as_str()) || state.jobs.contains(&job))
790                };
791                let own = states
792                    .iter()
793                    .filter(|state| served(state))
794                    .max_by_key(|state| state.started_unix)
795                    .cloned();
796                // A queued follow-up has no run of its own. The conversation
797                // it continues does, and that run has the directory and the
798                // agent's session.
799                let fallback = if own.is_none() {
800                    continued.as_deref().and_then(|handle| {
801                        states
802                            .iter()
803                            .filter(|state| {
804                                state.session == session
805                                    && state.conversation.as_deref() == Some(handle)
806                            })
807                            .max_by_key(|state| state.started_unix)
808                            .cloned()
809                    })
810                } else {
811                    None
812                };
813                let run = own.or_else(|| fallback.clone());
814                // Adopting while the job that run served is still interrupted
815                // would start a second process in its agent session. The
816                // follow-up waits until that job is resumed, then queues
817                // behind the conversation that resume adopted.
818                let blocking = match &fallback {
819                    Some(found) if found.vendor.is_some() && found.cwd.is_dir() => {
820                        unresumed_served(&jobs, found, &job)
821                    }
822                    _ => Vec::new(),
823                };
824                let live = run.as_ref().is_some_and(|run| {
825                    delegations
826                        .list(true)
827                        .iter()
828                        .any(|entry| entry.record.handle == run.handle && entry.processes > 0)
829                });
830                (successor, run, live, blocking)
831            })
832            .await
833            .map_err(|error| error.to_string())?
834        };
835        if let Some(resumed) = successor {
836            return Err(format!("{job} was already resumed as {resumed}"));
837        }
838        if live {
839            return Err(format!(
840                "{job}'s agent ({}) still runs; SCV stops what a stopped SCV process left \
841                 within a minute (or `scv agents kill` it now). Resume it after that",
842                run.as_ref().map_or("", |run| run.handle.as_str())
843            ));
844        }
845        if !blocking.is_empty() {
846            let ahead = if blocking.len() == 1 { "it" } else { "them" };
847            return Err(format!(
848                "{job} was queued behind {}; resume {ahead} first. This follow-up then waits \
849                 behind the same conversation, so one agent session is not continued twice",
850                blocking.join(", ")
851            ));
852        }
853        let mut record = record;
854        let cwd_live = run.as_ref().is_some_and(|found| found.cwd.is_dir());
855        if let Some(found) = &run
856            && cwd_live
857        {
858            remember_cwd(&mut record, &found.cwd);
859            if record.worktree.is_none()
860                && let Some(worktree) =
861                    crate::delegate::worktree::worktree_ref(&self.worktrees, &found.cwd)
862            {
863                record.worktree = Some(worktree);
864            }
865        }
866        // The directory is gone (the owner removed the worktree; an
867        // interrupted job does not hold it). The job that run served still
868        // records the checkout, dismissed records included, which is what a
869        // new worktree is made from. The run's path still names the
870        // subdirectory inside it.
871        if record.worktree.is_none()
872            && let Some(found) = &run
873            && let Some(worktree) = served_worktree(&self.jobs, found)
874        {
875            record.worktree = Some(worktree);
876        }
877        if !cwd_live
878            && let Some(found) = &run
879            && let Some(worktree) = &record.worktree
880            && found.cwd.starts_with(Path::new(&worktree.path))
881        {
882            remember_cwd(&mut record, &found.cwd);
883        }
884        let cwd = run
885            .as_ref()
886            .map(|run| run.cwd.clone())
887            .filter(|cwd| cwd.is_dir());
888        let adopted = run
889            .as_ref()
890            .and_then(|run| run.vendor.as_deref().zip(cwd.as_deref()))
891            .and_then(|(vendor, cwd)| agent.adopt(&record.agent, cwd, vendor));
892        let mut arguments = if let Some(handle) = &adopted {
893            json!({
894                "session": handle,
895                "prompt": format!(
896                    "[SCV] SCV stopped before your last turn on this task finished (a restart \
897                     or a crash), so it was cut off. Check where the work stands (files, git \
898                     status, anything still running), then carry on. The task was:\n\n{}",
899                    record.prompt
900                ),
901            })
902        } else {
903            rerun_arguments(&record, |option| agent.takes(&record.agent, option))
904        };
905        if let Some(object) = arguments.as_object_mut() {
906            for key in ["timeout_seconds", "no_timeout"] {
907                if let Some(value) = record.arguments.get(key) {
908                    object.insert(key.into(), value.clone());
909                }
910            }
911        }
912        // The new job's record says what it resumes from its first write.
913        let meta = JobMeta {
914            resumed_from: Some(key.to_owned()),
915            worktree: record
916                .worktree
917                .clone()
918                .filter(|worktree| Path::new(&worktree.path).is_dir()),
919            ..JobMeta::default()
920        };
921        let resumed = self.start_with(host, arguments, meta).await?;
922        ledger::update(&self.jobs, session, job, |old| {
923            old.resumed_as = Some(resumed.clone());
924            if !old.state.is_final() {
925                ledger::interrupt(old, "its session ended before it finished");
926            }
927        });
928        Ok(resumed)
929    }
930
931    /// Stop job `job` of live session `session`.
932    pub fn cancel(&self, session: &str, job: &str) -> Result<String, String> {
933        let jobs = {
934            let inner = lock(&self.inner);
935            inner
936                .sessions
937                .get(session)
938                .and_then(|slot| slot.jobs.as_ref())
939                .and_then(Weak::upgrade)
940        };
941        let Some(jobs) = jobs else {
942            return Err(format!(
943                "session {session} does not run in this daemon; a job whose session ended \
944                 stopped with it, and `scv agents kill` stops another process's runs"
945            ));
946        };
947        jobs.cancel_for_owner(job)?;
948        Ok(format!(
949            "Stopping {job}: its agent and every process it started are stopped; work it \
950             already wrote stays."
951        ))
952    }
953
954    /// Forget finished or interrupted job `job` of `session`.
955    pub fn dismiss(&self, session: &str, job: &str) -> Result<String, String> {
956        let record = ledger::load(&self.jobs, session, job)
957            .ok_or_else(|| format!("no record of {session}/{job}"))?;
958        let jobs = lock(&self.inner)
959            .sessions
960            .get(session)
961            .and_then(|slot| slot.jobs.as_ref())
962            .and_then(Weak::upgrade);
963        let running = jobs.as_ref().is_some_and(|jobs| {
964            jobs.live()
965                .iter()
966                .any(|live| live.id == job && live.running)
967        });
968        if running || (!record.state.is_final() && record.owner.is_alive()) {
969            return Err(format!("{job} is still running; stop it first"));
970        }
971        // A session that has yet to report the result writes its record
972        // again when it does: this daemon's says so itself, and one of
973        // another live SCV process by its record.
974        let elsewhere =
975            record.owner.is_alive() && ProcessIdentity::current().as_ref() != Some(&record.owner);
976        if jobs.is_some_and(|jobs| jobs.unreported(job)) || (elsewhere && !record.reported) {
977            return Err(format!(
978                "{job}'s result has not reached its session yet; dismiss it once it has"
979            ));
980        }
981        ledger::dismiss(&self.jobs, session, job).map_err(|error| error.to_string())?;
982        Ok(format!("Forgot {session}/{job}."))
983    }
984
985    /// The ledger record of `job` in `session`.
986    pub fn job_record(&self, session: &str, job: &str) -> Option<JobRecord> {
987        ledger::load(&self.jobs, session, job)
988    }
989
990    /// Settle what processes that stopped left unfinished, and remove old
991    /// records: the daemon runs this at startup and every minute.
992    pub fn maintain(&self) -> Maintenance {
993        let interrupted_jobs = ledger::settle_orphans(&self.jobs)
994            .into_iter()
995            .map(|record| format!("{}/{}", record.session, record.job))
996            .collect();
997        let interrupted_runs = activity::settle_orphans(&self.runs);
998        let pruned = ledger::prune(&self.jobs, KEEP) + activity::prune(&self.runs, KEEP, MAX_RUNS);
999        Maintenance {
1000            interrupted_jobs,
1001            interrupted_runs,
1002            pruned,
1003        }
1004    }
1005}
1006
1007/// A job claimed for resuming, released when dropped.
1008struct Claim<'a> {
1009    claims: &'a Mutex<HashSet<String>>,
1010    key: &'a str,
1011}
1012
1013impl Drop for Claim<'_> {
1014    fn drop(&mut self) {
1015        lock(self.claims).remove(self.key);
1016    }
1017}
1018
1019struct SlotView {
1020    id: String,
1021    label: String,
1022    workspace: PathBuf,
1023    state: AgentState,
1024    detail: Option<String>,
1025    agent: Option<Arc<BackgroundCapable>>,
1026    jobs: Option<Arc<BackgroundJobs>>,
1027}
1028
1029/// What a job is doing, from its record, its live entry when its session
1030/// runs here, the run serving it, and an approval it waits on.
1031fn job_state(
1032    record: &JobRecord,
1033    live: Option<&LiveJob>,
1034    run: Option<&RunState>,
1035    awaiting: Option<&WorkbenchApproval>,
1036    session_live: bool,
1037    now: u64,
1038) -> (AgentState, Option<String>) {
1039    if record.state.is_final() {
1040        let detail = match record.state {
1041            AgentState::Interrupted => record.interrupted.clone(),
1042            AgentState::Completed => None,
1043            _ => record
1044                .error
1045                .as_deref()
1046                .and_then(|error| error.lines().find(|line| !line.trim().is_empty()))
1047                .map(|line| bounded(line, 200)),
1048        };
1049        return (record.state, detail);
1050    }
1051    if let Some(approval) = awaiting {
1052        return (
1053            AgentState::AwaitingInput,
1054            Some(format!(
1055                "waiting for your approval: {}",
1056                bounded(&approval.summary, 200)
1057            )),
1058        );
1059    }
1060    if let Some(live) = live {
1061        if live.waiting {
1062            let behind = record
1063                .arguments
1064                .get("session")
1065                .and_then(Value::as_str)
1066                .map_or_else(String::new, |handle| format!(" {handle}'s"));
1067            return (
1068                AgentState::Queued,
1069                Some(format!("waits for conversation{behind} running turn")),
1070            );
1071        }
1072        if live.running {
1073            if let Some(run) = run.filter(|run| !run.ended) {
1074                return activity::current(run, now);
1075            }
1076            return (
1077                AgentState::Working,
1078                live.progress
1079                    .clone()
1080                    .or_else(|| Some(format!("running for {} s", live.elapsed.as_secs()))),
1081            );
1082        }
1083    }
1084    if !record.owner.is_alive() {
1085        return (
1086            AgentState::Interrupted,
1087            Some("the SCV process that ran it stopped".into()),
1088        );
1089    }
1090    if record.owner.pid == std::process::id() && !session_live {
1091        return (
1092            AgentState::Interrupted,
1093            Some("its session ended before it finished".into()),
1094        );
1095    }
1096    match run {
1097        Some(run) if !run.ended => activity::current(run, now),
1098        _ => (record.state, None),
1099    }
1100}
1101
1102/// The workbench's own session ID for `workspace`: stable, so the owner's
1103/// jobs in one workspace group under one session across daemon restarts.
1104pub fn host_session(workspace: &Path) -> String {
1105    use sha2::{Digest, Sha256};
1106    let digest = Sha256::digest(workspace.as_os_str().as_encoded_bytes());
1107    let hex: String = digest[..8]
1108        .iter()
1109        .map(|byte| format!("{byte:02x}"))
1110        .collect();
1111    format!("workbench-{hex}")
1112}
1113
1114/// `<session>/<job>` of a job an `agent` call started.
1115fn job_name(session: &str, started: &Value) -> String {
1116    format!(
1117        "{session}/{}",
1118        started.get("job").and_then(Value::as_str).unwrap_or("job")
1119    )
1120}
1121
1122/// Decides a background job's approval request: the session's unattended
1123/// answer, or, for one it would deny while the owner watches, the owner's.
1124struct AttendedGate {
1125    workbench: Weak<Workbench>,
1126    session: String,
1127    unattended: Arc<dyn ApprovalGate>,
1128}
1129
1130#[async_trait]
1131impl ApprovalGate for AttendedGate {
1132    async fn approve(
1133        &self,
1134        request: ApprovalRequest,
1135        cancellation: CancellationToken,
1136    ) -> Result<bool, AgentError> {
1137        if self
1138            .unattended
1139            .approve(request.clone(), cancellation.clone())
1140            .await?
1141        {
1142            return Ok(true);
1143        }
1144        match self.workbench.upgrade() {
1145            Some(workbench) if workbench.watched() => {
1146                workbench.ask(&self.session, request, &cancellation).await
1147            }
1148            _ => Ok(false),
1149        }
1150    }
1151}
1152
1153fn bounded(text: &str, max: usize) -> String {
1154    if text.chars().count() <= max {
1155        return text.to_owned();
1156    }
1157    let mut cut: String = text.chars().take(max.saturating_sub(1)).collect();
1158    cut.push('…');
1159    cut
1160}
1161
1162fn unix_now() -> u64 {
1163    SystemTime::now()
1164        .duration_since(UNIX_EPOCH)
1165        .map_or(0, |elapsed| elapsed.as_secs())
1166}
1167
1168/// Jobs whose turns `run` served, the latest one first.
1169fn served_job_names(run: &RunState) -> Vec<&str> {
1170    let mut names = Vec::new();
1171    if let Some(job) = run.job.as_deref() {
1172        names.push(job);
1173    }
1174    for job in &run.jobs {
1175        if !names.contains(&job.as_str()) {
1176            names.push(job.as_str());
1177        }
1178    }
1179    names
1180}
1181
1182/// Interrupted jobs `run` served that the owner has not resumed, as
1183/// `session/job`. `skip` is the follow-up being resumed.
1184fn unresumed_served(root: &Path, run: &RunState, skip: &str) -> Vec<String> {
1185    served_job_names(run)
1186        .into_iter()
1187        .filter(|name| *name != skip)
1188        .filter_map(|name| {
1189            let record = ledger::load(root, &run.session, name)?;
1190            record
1191                .resumable()
1192                .then(|| format!("{}/{}", run.session, name))
1193        })
1194        .collect()
1195}
1196
1197/// The worktree recorded on a job `run` served. A follow-up has none of
1198/// its own, and the run's directory may already have been removed. A
1199/// dismissed record still carries the worktree.
1200fn served_worktree(root: &Path, run: &RunState) -> Option<scv_protocol::WorktreeRef> {
1201    served_job_names(run).into_iter().find_map(|name| {
1202        ledger::load_including_dismissed(root, &run.session, name)
1203            .and_then(|record| record.worktree)
1204    })
1205}
1206
1207/// Record `cwd` on `record` when the call did not name one.
1208fn remember_cwd(record: &mut ledger::JobRecord, cwd: &Path) {
1209    let has_cwd = record
1210        .arguments
1211        .get("cwd")
1212        .and_then(Value::as_str)
1213        .is_some_and(|cwd| !cwd.is_empty());
1214    if !has_cwd && let Some(object) = record.arguments.as_object_mut() {
1215        object.insert("cwd".into(), cwd.display().to_string().into());
1216    }
1217}
1218
1219/// The `agent` call that runs interrupted job `record` again from its
1220/// prompt in a new conversation: its options but those the host agent does
1221/// not `take`, and a note that an earlier attempt may have left changes. A
1222/// job that ran in a worktree runs in the same directory of it while the
1223/// worktree is there, and in a new worktree of its repository once it was
1224/// removed, so its work never lands in the repository's own checkout.
1225fn rerun_arguments(record: &JobRecord, takes: impl Fn(&str) -> bool) -> Value {
1226    let mut arguments = record.arguments.clone();
1227    if let Some(object) = arguments.as_object_mut() {
1228        object.remove("session");
1229        object.remove("worktree");
1230        object.remove("on_busy");
1231        // What this session's agent does not take is left to its own
1232        // defaults.
1233        for option in ["model", "effort"] {
1234            if !takes(option) {
1235                object.remove(option);
1236            }
1237        }
1238        object.insert("agent".into(), record.agent.clone().into());
1239        object.insert(
1240            "prompt".into(),
1241            format!(
1242                "[SCV] An earlier attempt at this task was cut off when SCV stopped (a restart \
1243                 or a crash), and its conversation could not be resumed. It may have left \
1244                 partial changes here: check them first, then do the task:\n\n{}",
1245                record.prompt
1246            )
1247            .into(),
1248        );
1249        if let Some(worktree) = &record.worktree {
1250            let top = Path::new(&worktree.path);
1251            // The call's directory inside the worktree, as it was recorded.
1252            let inside = object
1253                .get("cwd")
1254                .and_then(Value::as_str)
1255                .and_then(|cwd| Path::new(cwd).strip_prefix(top).ok())
1256                .map(Path::to_path_buf)
1257                .unwrap_or_default();
1258            if top.is_dir() {
1259                let cwd = Some(top.join(&inside))
1260                    .filter(|cwd| cwd.is_dir())
1261                    .unwrap_or_else(|| top.to_owned());
1262                object.insert("cwd".into(), cwd.display().to_string().into());
1263            } else {
1264                let repo = Path::new(&worktree.repo);
1265                let cwd = Some(repo.join(&inside))
1266                    .filter(|cwd| cwd.is_dir())
1267                    .unwrap_or_else(|| repo.to_owned());
1268                object.insert("cwd".into(), cwd.display().to_string().into());
1269                object.insert("worktree".into(), true.into());
1270            }
1271        }
1272    }
1273    arguments
1274}
1275
1276#[cfg(test)]
1277mod tests;