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//! - a conversation taken over in its agent's own terminal
22//!   ([`Workbench::attach_terminal`], or [`Workbench::interrupt_and_attach`],
23//!   which stops the job running its turn once its lane is held): the owner
24//!   reads its screen and types into it ([`Workbench::attach_io`]), detaches
25//!   while it keeps running ([`Workbench::detach`]), and ends it
26//!   ([`Workbench::end_takeover`]). A takeover ends with its session, and
27//!   with the daemon. The snapshot says of each conversation whether it can
28//!   be taken over now, and why not.
29
30use std::{
31    collections::{BTreeMap, HashMap, HashSet},
32    path::{Path, PathBuf},
33    sync::{
34        Arc, Mutex, Weak,
35        atomic::{AtomicBool, Ordering},
36    },
37    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
38};
39
40use async_trait::async_trait;
41use scv_client::Layout;
42use scv_core::{AgentError, ApprovalGate, ApprovalRequest};
43use scv_protocol::{
44    AgentState, AttachOffer, AttachPage, AttachState, JobResult, MAX_JOB_RESULT_BYTES, RunLogPage,
45    SendMode, WorkbenchApproval, WorkbenchJob, WorkbenchRun, WorkbenchSession, WorkbenchSnapshot,
46};
47use serde_json::{Value, json};
48use tokio::sync::oneshot;
49use tokio_util::sync::CancellationToken;
50
51use crate::{
52    delegate::{
53        activity::{self, RunState},
54        background::{
55            BackgroundCapable, BackgroundJobs, JobMeta, LiveJob, OwnerCall,
56            ledger::{self, JobRecord},
57        },
58        records::{DelegationRegistry, ProcessIdentity},
59        takeover::{self, Takeover},
60    },
61    sync::lock,
62};
63
64/// How long a background job's approval request waits for the owner while
65/// the owner watches, before it gets the session's unattended answer.
66pub const ATTENDED_WAIT: Duration = Duration::from_secs(120);
67/// How long one `watch` keeps the owner counted as watching.
68pub const WATCH_TTL: Duration = Duration::from_secs(15);
69/// Jobs and runs a snapshot lists at most: every unfinished one, then the
70/// newest finished ones.
71const MAX_LISTED: usize = 200;
72/// Finished jobs and ended runs are kept this long.
73pub const KEEP: Duration = Duration::from_secs(7 * 24 * 3600);
74/// Ended runs kept at most.
75pub const MAX_RUNS: usize = 500;
76/// How long a client attached to a taken-over terminal counts as watching
77/// after its last read: one that stops reading (its dashboard closed or
78/// died) is detached, and the agent keeps running.
79pub const ATTACH_LEASE: Duration = Duration::from_secs(30);
80/// How long a takeover whose terminal closed is kept, so a client still
81/// attached reads how it ended.
82const CLOSED_KEPT: Duration = Duration::from_secs(60);
83/// Takeovers that may run at once.
84const MAX_TAKEOVERS: usize = 8;
85
86/// The daemon's workbench.
87pub struct Workbench {
88    runs: PathBuf,
89    jobs: PathBuf,
90    worktrees: PathBuf,
91    delegations: Arc<DelegationRegistry>,
92    inner: Mutex<Inner>,
93    watched_until: Mutex<Option<Instant>>,
94    /// Jobs being resumed now, `<session>/<job>`: each is resumed once.
95    resuming: Mutex<HashSet<String>>,
96    /// Conversations taken over in their agents' terminals, by
97    /// `<session>/<conversation>`.
98    takeovers: Mutex<HashMap<String, Held>>,
99    /// Held while a takeover starts, so two `attach`es of one conversation
100    /// do not both start one. Attaching to one that runs never waits for it.
101    attaching: tokio::sync::Mutex<()>,
102    /// SCV is stopping: no takeover starts any more. Set and read with
103    /// `takeovers` locked, so none is kept after they all were ended.
104    closing: AtomicBool,
105}
106
107/// A takeover, and the client attached to it.
108struct Held {
109    takeover: Arc<Takeover>,
110    /// The attached client's hold, and when it last read.
111    lease: Option<(String, Instant)>,
112    /// When the terminal was first seen closed.
113    closed: Option<Instant>,
114}
115
116impl std::fmt::Debug for Workbench {
117    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        formatter.debug_struct("Workbench").finish_non_exhaustive()
119    }
120}
121
122#[derive(Default)]
123struct Inner {
124    sessions: HashMap<String, Slot>,
125    approvals: BTreeMap<String, Pending>,
126    next: u64,
127}
128
129/// A live session of this process.
130struct Slot {
131    token: u64,
132    label: String,
133    workspace: PathBuf,
134    state: AgentState,
135    detail: Option<String>,
136    agent: Option<Weak<BackgroundCapable>>,
137    jobs: Option<Weak<BackgroundJobs>>,
138}
139
140struct Pending {
141    info: WorkbenchApproval,
142    answer: Option<oneshot::Sender<bool>>,
143}
144
145/// A session's place in the workbench while it lives; dropping it removes
146/// the session.
147pub struct SessionHandle {
148    workbench: Weak<Workbench>,
149    id: String,
150    token: u64,
151}
152
153impl std::fmt::Debug for SessionHandle {
154    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        formatter
156            .debug_struct("SessionHandle")
157            .field("id", &self.id)
158            .finish()
159    }
160}
161
162impl SessionHandle {
163    /// What the session is doing: a turn runs, an approval waits for its
164    /// client, or it is idle.
165    pub fn set_state(&self, state: AgentState, detail: Option<String>) {
166        let Some(workbench) = self.workbench.upgrade() else {
167            return;
168        };
169        if let Some(slot) = lock(&workbench.inner).sessions.get_mut(&self.id)
170            && slot.token == self.token
171        {
172            slot.state = state;
173            slot.detail = detail;
174        }
175    }
176}
177
178impl Drop for SessionHandle {
179    fn drop(&mut self) {
180        let Some(workbench) = self.workbench.upgrade() else {
181            return;
182        };
183        let mut inner = lock(&workbench.inner);
184        if inner
185            .sessions
186            .get(&self.id)
187            .is_some_and(|slot| slot.token == self.token)
188        {
189            inner.sessions.remove(&self.id);
190            drop(inner);
191            // Its conversations go with it, taken over or not.
192            workbench.end_takeovers_of(Some(&self.id));
193        }
194    }
195}
196
197/// What [`Workbench::send`] did with the owner's prompt.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub enum Sent {
200    /// A background job of the session runs it: `<session>/<job>`.
201    Job(String),
202    /// It went into the conversation's running turn.
203    Steered,
204}
205
206/// What a maintenance pass did.
207#[derive(Debug, Clone, Default, PartialEq, Eq)]
208pub struct Maintenance {
209    /// Jobs found interrupted: their SCV process stopped.
210    pub interrupted_jobs: Vec<String>,
211    /// Runs found interrupted.
212    pub interrupted_runs: Vec<String>,
213    /// Old records removed.
214    pub pruned: usize,
215}
216
217impl Workbench {
218    /// The workbench of the instance at `layout`, whose delegated runs
219    /// `delegations` records.
220    pub fn new(layout: &Layout, delegations: Arc<DelegationRegistry>) -> Arc<Self> {
221        Arc::new(Self {
222            runs: layout.runs(),
223            jobs: layout.jobs(),
224            worktrees: layout.worktrees(),
225            delegations,
226            inner: Mutex::default(),
227            watched_until: Mutex::new(None),
228            resuming: Mutex::default(),
229            takeovers: Mutex::default(),
230            attaching: tokio::sync::Mutex::const_new(()),
231            closing: AtomicBool::new(false),
232        })
233    }
234
235    /// Where the job ledger is.
236    pub fn jobs_dir(&self) -> &Path {
237        &self.jobs
238    }
239
240    /// Register a live session; `label` says who it serves.
241    pub fn register_session(
242        self: &Arc<Self>,
243        id: &str,
244        label: &str,
245        workspace: &Path,
246    ) -> SessionHandle {
247        let mut inner = lock(&self.inner);
248        inner.next += 1;
249        let token = inner.next;
250        inner.sessions.insert(
251            id.to_owned(),
252            Slot {
253                token,
254                label: label.to_owned(),
255                workspace: workspace.to_owned(),
256                state: AgentState::Idle,
257                detail: None,
258                agent: None,
259                jobs: None,
260            },
261        );
262        SessionHandle {
263            workbench: Arc::downgrade(self),
264            id: id.to_owned(),
265            token,
266        }
267    }
268
269    /// The `agent` tool and job store of live session `session`.
270    pub(crate) fn attach(
271        &self,
272        session: &str,
273        agent: Weak<BackgroundCapable>,
274        jobs: Weak<BackgroundJobs>,
275    ) {
276        if let Some(slot) = lock(&self.inner).sessions.get_mut(session) {
277            slot.agent = Some(agent);
278            slot.jobs = Some(jobs);
279        }
280    }
281
282    /// The owner is watching: background approval requests wait for an
283    /// answer for the next [`WATCH_TTL`].
284    pub fn watch(&self) {
285        *lock(&self.watched_until) = Some(Instant::now() + WATCH_TTL);
286    }
287
288    fn watched(&self) -> bool {
289        lock(&self.watched_until).is_some_and(|until| Instant::now() < until)
290    }
291
292    /// The approval gate for background jobs of `session`: `unattended`
293    /// decides, except that a request it would deny waits up to
294    /// [`ATTENDED_WAIT`] for the owner while the owner watches. It never
295    /// denies what `unattended` allows.
296    pub fn attended_gate(
297        self: &Arc<Self>,
298        session: &str,
299        unattended: Arc<dyn ApprovalGate>,
300    ) -> Arc<dyn ApprovalGate> {
301        Arc::new(AttendedGate {
302            workbench: Arc::downgrade(self),
303            session: session.to_owned(),
304            unattended,
305        })
306    }
307
308    /// Hold `request` of a job of `session` for the owner's answer.
309    async fn ask(
310        &self,
311        session: &str,
312        request: ApprovalRequest,
313        cancellation: &CancellationToken,
314    ) -> Result<bool, AgentError> {
315        let (sender, receiver) = oneshot::channel();
316        let now = unix_now();
317        let id = format!("ap-{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
318        lock(&self.inner).approvals.insert(
319            id.clone(),
320            Pending {
321                info: WorkbenchApproval {
322                    id: id.clone(),
323                    session: session.to_owned(),
324                    job: request.call_id.clone(),
325                    name: request.name.clone(),
326                    risk: request.risk.as_str().to_owned(),
327                    cwd: request.cwd.display().to_string(),
328                    summary: request.summary.clone(),
329                    since_unix_seconds: now,
330                    expires_unix_seconds: now + ATTENDED_WAIT.as_secs(),
331                },
332                answer: Some(sender),
333            },
334        );
335        // An answer that lands as the deadline fires is still the answer.
336        // Closing the receiver and taking anything already sent stops the
337        // waiter from reporting a denial after `approve` has sent `true`.
338        let mut receiver = receiver;
339        let answer = tokio::select! {
340            biased;
341            answer = &mut receiver => Ok(answer.unwrap_or(false)),
342            () = tokio::time::sleep(ATTENDED_WAIT) => {
343                receiver.close();
344                Ok(receiver.try_recv().unwrap_or(false))
345            }
346            () = cancellation.cancelled() => {
347                receiver.close();
348                match receiver.try_recv() {
349                    Ok(approved) => Ok(approved),
350                    Err(_) => Err(AgentError::Cancelled),
351                }
352            }
353        };
354        lock(&self.inner).approvals.remove(&id);
355        answer
356    }
357
358    /// Answer approval request `id`.
359    pub fn approve(&self, id: &str, approved: bool) -> Result<String, String> {
360        let pending = lock(&self.inner).approvals.get_mut(id).and_then(|pending| {
361            pending
362                .answer
363                .take()
364                .map(|answer| (answer, pending.info.clone()))
365        });
366        let Some((answer, info)) = pending else {
367            return Err(format!(
368                "no approval request {id:?} waits; it may have been answered or timed out"
369            ));
370        };
371        if answer.send(approved).is_err() {
372            return Err(format!("{} no longer waits for this answer", info.job));
373        }
374        Ok(format!(
375            "{} {}: {}",
376            if approved { "Approved" } else { "Denied" },
377            info.job,
378            bounded(&info.summary, 120)
379        ))
380    }
381
382    /// Everything the workbench shows now. `restorable` is left for the
383    /// server, which keeps session queues.
384    pub fn snapshot(&self) -> WorkbenchSnapshot {
385        self.expire_takeovers();
386        let now = unix_now();
387        let live_handles = self.live_handles();
388        let (slots, approvals): (Vec<_>, Vec<_>) = {
389            let inner = lock(&self.inner);
390            (
391                inner
392                    .sessions
393                    .iter()
394                    .map(|(id, slot)| SlotView {
395                        id: id.clone(),
396                        label: slot.label.clone(),
397                        workspace: slot.workspace.clone(),
398                        state: slot.state,
399                        detail: slot.detail.clone(),
400                        agent: slot.agent.as_ref().and_then(Weak::upgrade),
401                        jobs: slot.jobs.as_ref().and_then(Weak::upgrade),
402                    })
403                    .collect(),
404                inner
405                    .approvals
406                    .values()
407                    .map(|pending| pending.info.clone())
408                    .collect(),
409            )
410        };
411        let live_jobs: HashMap<(String, String), LiveJob> = slots
412            .iter()
413            .filter_map(|slot| {
414                slot.jobs
415                    .as_ref()
416                    .map(|jobs| (slot.id.clone(), jobs.live()))
417            })
418            .flat_map(|(session, jobs)| {
419                jobs.into_iter()
420                    .map(move |job| ((session.clone(), job.id.clone()), job))
421            })
422            .collect();
423        let live_sessions: HashSet<&str> = slots.iter().map(|slot| slot.id.as_str()).collect();
424        let awaiting: HashMap<(String, String), &WorkbenchApproval> = approvals
425            .iter()
426            .map(|approval| ((approval.session.clone(), approval.job.clone()), approval))
427            .collect();
428
429        // A conversation handle is shown only while the live session that
430        // made it still has it, so the owner can send it a prompt: one an
431        // earlier daemon, or an earlier session under an ID that came back
432        // (a workbench session), left names nothing now. Such a session
433        // numbers its conversations after the ones its records name, so a
434        // handle never comes to mean another conversation.
435        let me = ProcessIdentity::current();
436        let held = |owner: &ProcessIdentity, session: &str, handle: &str| {
437            me.as_ref() == Some(owner)
438                && slots.iter().any(|slot| {
439                    slot.id == session
440                        && slot.agent.as_ref().is_some_and(|agent| agent.knows(handle))
441                })
442        };
443        // Whether the owner can take each such conversation over now.
444        let taken_over = self.running_takeovers();
445        let mut offers: HashMap<(String, String), Option<AttachOffer>> = HashMap::new();
446        let mut offer = |session: &str, handle: &str| {
447            offers
448                .entry((session.to_owned(), handle.to_owned()))
449                .or_insert_with(|| {
450                    let slot = slots.iter().find(|slot| slot.id == session)?;
451                    let offer = slot.agent.as_ref()?.attach_offer(handle)?;
452                    let approval = offer.job.as_ref().and_then(|job| {
453                        awaiting
454                            .get(&(session.to_owned(), job.clone()))
455                            .map(|approval| approval.id.as_str())
456                    });
457                    Some(settle_offer(offer, approval, taken_over))
458                })
459                .clone()
460        };
461        let mut states = activity::list_states(&self.runs);
462        states
463            .sort_by_key(|state| std::cmp::Reverse((state.last_activity_unix, state.started_unix)));
464        let mut runs: Vec<WorkbenchRun> = Vec::new();
465        let mut job_runs: HashMap<(String, String), &RunState> = HashMap::new();
466        for state in &states {
467            for job in state.jobs.iter().chain(&state.job) {
468                job_runs
469                    .entry((state.session.clone(), job.clone()))
470                    .or_insert(state);
471            }
472        }
473        let mut ended_listed = 0;
474        for state in &states {
475            if state.ended {
476                if ended_listed >= MAX_LISTED {
477                    continue;
478                }
479                ended_listed += 1;
480            }
481            let (mut current, mut detail) = activity::current(state, now);
482            if let Some(job) = &state.job
483                && let Some(approval) = awaiting.get(&(state.session.clone(), job.clone()))
484                && !state.ended
485            {
486                current = AgentState::AwaitingInput;
487                detail = Some(format!(
488                    "waiting for your approval: {}",
489                    bounded(&approval.summary, 200)
490                ));
491            }
492            let conversation = state
493                .conversation
494                .clone()
495                .filter(|handle| held(&state.owner, &state.session, handle));
496            runs.push(WorkbenchRun {
497                handle: state.handle.clone(),
498                agent: state.agent.clone(),
499                session: state.session.clone(),
500                transport: state.transport.clone(),
501                attach: conversation
502                    .as_deref()
503                    .and_then(|handle| offer(&state.session, handle)),
504                conversation,
505                turn: state.turn,
506                job: state.job.clone(),
507                jobs: state.jobs.clone(),
508                cwd: state.cwd.display().to_string(),
509                state: current,
510                detail,
511                started_unix_seconds: state.started_unix,
512                last_activity_unix_seconds: state.last_activity_unix,
513                live: !state.ended && live_handles.contains(&state.handle),
514                steerable: state.steerable,
515            });
516        }
517
518        let all = ledger::list_all(&self.jobs);
519        let mut records: Vec<&JobRecord> = all.iter().filter(|record| !record.dismissed).collect();
520        records.sort_by_key(|record| std::cmp::Reverse((record.created_unix, record.job.clone())));
521        let mut jobs = Vec::new();
522        let mut finished_listed = 0;
523        // What each job was resumed as, by the record of the job resuming
524        // it too, which is written first: a dismissed one's included, or
525        // the job would be resumable again.
526        let successors: HashMap<&str, String> = all
527            .iter()
528            .filter_map(|record| {
529                let from = record.resumed_from.as_deref()?;
530                Some((from, format!("{}/{}", record.session, record.job)))
531            })
532            .collect();
533        for &record in &records {
534            let key = (record.session.clone(), record.job.clone());
535            let resumed_as = record.resumed_as.clone().or_else(|| {
536                successors
537                    .get(format!("{}/{}", record.session, record.job).as_str())
538                    .cloned()
539            });
540            let live = live_jobs.get(&key);
541            let run = job_runs.get(&key).copied();
542            let (state, detail) = job_state(
543                record,
544                live,
545                run,
546                awaiting.get(&key).copied(),
547                live_sessions.contains(record.session.as_str()),
548                now,
549            );
550            // An interrupted job the owner can still resume stays listed
551            // after the cap of other finished jobs. Counting it as finished
552            // would hide it once newer completions filled the snapshot.
553            let resumable_interrupted =
554                state == AgentState::Interrupted && resumed_as.is_none() && !record.reviewed;
555            if state.is_final() && !resumable_interrupted {
556                if finished_listed >= MAX_LISTED {
557                    continue;
558                }
559                finished_listed += 1;
560            }
561            let cwd = run
562                .map(|run| run.cwd.display().to_string())
563                .or_else(|| {
564                    record
565                        .worktree
566                        .as_ref()
567                        .map(|worktree| worktree.path.clone())
568                })
569                .unwrap_or_else(|| {
570                    let relative = record.arguments.get("cwd").and_then(Value::as_str);
571                    relative.map_or_else(
572                        || record.workspace.display().to_string(),
573                        |cwd| record.workspace.join(cwd).display().to_string(),
574                    )
575                });
576            let conversation = record
577                .conversation
578                .clone()
579                .or_else(|| run.and_then(|run| run.conversation.clone()))
580                .or_else(|| {
581                    record
582                        .arguments
583                        .get("session")
584                        .and_then(Value::as_str)
585                        .map(str::to_owned)
586                })
587                .filter(|handle| held(&record.owner, &record.session, handle));
588            jobs.push(WorkbenchJob {
589                session: record.session.clone(),
590                job: record.job.clone(),
591                agent: record.agent.clone(),
592                task: record.task.clone(),
593                workspace: record.workspace.display().to_string(),
594                cwd,
595                state,
596                detail,
597                started_by: record.started_by,
598                attach: conversation
599                    .as_deref()
600                    .and_then(|handle| offer(&record.session, handle)),
601                conversation,
602                run: run.map(|run| run.handle.clone()),
603                worktree: record.worktree.clone(),
604                reviewed: record.reviewed,
605                resumable: state == AgentState::Interrupted
606                    && !record.reviewed
607                    && resumed_as.is_none(),
608                resumed_as,
609                created_unix_seconds: record.created_unix,
610                updated_unix_seconds: record.updated_unix,
611            });
612        }
613
614        let mut sessions: Vec<WorkbenchSession> = slots
615            .iter()
616            .map(|slot| WorkbenchSession {
617                id: slot.id.clone(),
618                label: slot.label.clone(),
619                workspace: slot.workspace.display().to_string(),
620                live: true,
621                state: slot.state,
622                detail: slot.detail.clone(),
623            })
624            .collect();
625        let mut seen: HashSet<String> = sessions.iter().map(|session| session.id.clone()).collect();
626        for &record in &records {
627            if seen.insert(record.session.clone()) {
628                let alive = record.owner.is_alive() && record.owner.pid != std::process::id();
629                sessions.push(WorkbenchSession {
630                    id: record.session.clone(),
631                    label: record.label.clone(),
632                    workspace: record.workspace.display().to_string(),
633                    live: alive,
634                    state: AgentState::Unknown,
635                    detail: Some(if alive {
636                        format!("runs in SCV process {}", record.owner.pid)
637                    } else {
638                        "ended".to_owned()
639                    }),
640                });
641            }
642        }
643        sessions.sort_by(|a, b| b.live.cmp(&a.live).then_with(|| a.id.cmp(&b.id)));
644        WorkbenchSnapshot {
645            generated_unix_seconds: now,
646            sessions,
647            jobs,
648            runs,
649            approvals,
650            restorable: Vec::new(),
651        }
652    }
653
654    /// The runs whose processes still run, in any SCV process of the
655    /// instance.
656    fn live_handles(&self) -> HashSet<String> {
657        self.delegations
658            .list(true)
659            .into_iter()
660            .filter(|entry| entry.processes > 0)
661            .map(|entry| entry.record.handle)
662            .collect()
663    }
664
665    /// Refuse to remove the worktree holding `path` while anything still
666    /// works in it ([`Self::worktree_users`]).
667    pub fn check_worktree_unused(&self, path: &Path) -> Result<(), String> {
668        let users = self.worktree_users(path);
669        if users.is_empty() {
670            return Ok(());
671        }
672        Err(format!(
673            "{} still work in {}; stop them before removing it",
674            users.join(", "),
675            path.display()
676        ))
677    }
678
679    /// What still runs in the SCV worktree holding `path` (its top, or any
680    /// directory in it): jobs not yet finished that recorded it, and
681    /// delegated runs whose processes work inside it.
682    pub fn worktree_users(&self, path: &Path) -> Vec<String> {
683        let path = crate::delegate::worktree::worktree_path(&self.worktrees, path)
684            .or_else(|| std::fs::canonicalize(path).ok())
685            .unwrap_or_else(|| path.to_owned());
686        let mut users: Vec<String> = ledger::list(&self.jobs)
687            .into_iter()
688            .filter(|record| {
689                !record.state.is_final()
690                    && record.owner.is_alive()
691                    && record
692                        .worktree
693                        .as_ref()
694                        .is_some_and(|worktree| Path::new(&worktree.path) == path)
695            })
696            .map(|record| format!("{}/{}", record.session, record.job))
697            .collect();
698        users.extend(
699            self.delegations
700                .list(true)
701                .into_iter()
702                .filter(|entry| entry.processes > 0 && entry.record.cwd.starts_with(&path))
703                .map(|entry| entry.record.handle),
704        );
705        users
706    }
707
708    /// One page of run `handle`'s activity log after byte position `after`.
709    pub fn run_log(&self, handle: &str, after: u64, limit: usize) -> Result<RunLogPage, String> {
710        activity::read_page(&self.runs, handle, after, limit).map_err(|error| {
711            if error.kind() == std::io::ErrorKind::NotFound {
712                format!("no activity log for run {handle:?}")
713            } else {
714                format!("cannot read run {handle:?}: {error}")
715            }
716        })
717    }
718
719    /// The `agent` tool of live session `session`.
720    fn agent(&self, session: &str) -> Result<(Arc<BackgroundCapable>, PathBuf), String> {
721        let inner = lock(&self.inner);
722        let slot = inner.sessions.get(session).ok_or_else(|| {
723            format!("session {session} does not run in this daemon (it ended, or another SCV process runs it)")
724        })?;
725        let agent = slot
726            .agent
727            .as_ref()
728            .and_then(Weak::upgrade)
729            .ok_or_else(|| format!("session {session} runs no background agent jobs"))?;
730        Ok((agent, slot.workspace.clone()))
731    }
732
733    /// Give conversation `conversation` of live session `session` the
734    /// owner's `prompt`.
735    pub async fn send(
736        &self,
737        session: &str,
738        conversation: &str,
739        prompt: &str,
740        mode: SendMode,
741    ) -> Result<Sent, String> {
742        let (agent, workspace) = self.agent(session)?;
743        let call = agent
744            .owner_call(
745                json!({"session": conversation, "prompt": prompt}),
746                workspace,
747                mode,
748                JobMeta::default(),
749            )
750            .await
751            .map_err(|error| error.message)?;
752        Ok(match call {
753            OwnerCall::Job(started) => Sent::Job(job_name(session, &started)),
754            OwnerCall::Steered => Sent::Steered,
755        })
756    }
757
758    /// Start the owner's own job in live session `session` with the `agent`
759    /// call `arguments`. Returns `<session>/<job>`.
760    pub async fn start(&self, session: &str, arguments: Value) -> Result<String, String> {
761        self.start_with(session, arguments, JobMeta::default())
762            .await
763    }
764
765    /// [`Self::start`], with what the job's record holds from the start.
766    async fn start_with(
767        &self,
768        session: &str,
769        arguments: Value,
770        meta: JobMeta,
771    ) -> Result<String, String> {
772        let (agent, workspace) = self.agent(session)?;
773        match agent
774            .owner_call(arguments, workspace, SendMode::Queue, meta)
775            .await
776            .map_err(|error| error.message)?
777        {
778            OwnerCall::Job(started) => Ok(job_name(session, &started)),
779            OwnerCall::Steered => Err("the prompt went into a running turn".into()),
780        }
781    }
782
783    /// Run interrupted job `job` of `session` again as the owner's job in
784    /// live session `host`, which serves the job's workspace. It continues
785    /// the agent's own conversation when the agent saved one and can resume
786    /// it; otherwise it starts over, told that an earlier attempt may have
787    /// left changes. A queued follow-up has no run of its own: it uses the
788    /// conversation's directory and worktree, continues that agent session
789    /// only after the interrupted job the run served has been resumed (and
790    /// then behind that conversation), and otherwise starts in a new
791    /// worktree of the checkout that job recorded. Each job is resumed
792    /// once, and not while an agent process of its run still goes. Returns
793    /// `<host>/<new job>`.
794    pub async fn resume(&self, host: &str, session: &str, job: &str) -> Result<String, String> {
795        let key = format!("{session}/{job}");
796        if !lock(&self.resuming).insert(key.clone()) {
797            return Err(format!("{job} is being resumed already"));
798        }
799        // Released however this ends, a dropped request included.
800        let _claim = Claim {
801            claims: &self.resuming,
802            key: &key,
803        };
804        self.resume_once(host, session, job, &key).await
805    }
806
807    async fn resume_once(
808        &self,
809        host: &str,
810        session: &str,
811        job: &str,
812        key: &str,
813    ) -> Result<String, String> {
814        let record = ledger::load(&self.jobs, session, job)
815            .ok_or_else(|| format!("no record of {session}/{job}"))?;
816        let session_live = lock(&self.inner).sessions.contains_key(session);
817        let interrupted = record.state == AgentState::Interrupted
818            || (!record.state.is_final()
819                && (!record.owner.is_alive()
820                    || (record.owner.pid == std::process::id() && !session_live)));
821        if !interrupted {
822            return Err(format!(
823                "{job} was not interrupted ({}); only an interrupted job can be resumed",
824                record.state
825            ));
826        }
827        if record.reviewed {
828            return Err(
829                "a reviewed job is not resumed: its journal is its record. Start a new \
830                 reviewed call whose brief restates the open findings and anything already \
831                 landed"
832                    .into(),
833            );
834        }
835        if let Some(resumed) = &record.resumed_as {
836            return Err(format!("{job} was already resumed as {resumed}"));
837        }
838        let (agent, _) = self.agent(host)?;
839        // Reading every record, run state, and the process table takes a
840        // while.
841        let (successor, run, live, blocking) = {
842            let (jobs, runs) = (self.jobs.clone(), self.runs.clone());
843            let delegations = Arc::clone(&self.delegations);
844            let continued = record
845                .arguments
846                .get("session")
847                .and_then(Value::as_str)
848                .map(str::to_owned);
849            let (session, job, key) = (session.to_owned(), job.to_owned(), key.to_owned());
850            tokio::task::spawn_blocking(move || {
851                // A job that names it as what it resumes settles it, should
852                // SCV have stopped before this record said so, even once
853                // the owner dismissed that job.
854                let successor = ledger::list_all(&jobs)
855                    .into_iter()
856                    .find(|record| record.resumed_from.as_deref() == Some(key.as_str()))
857                    .map(|record| format!("{}/{}", record.session, record.job));
858                let states = activity::list_states(&runs);
859                let served = |state: &activity::RunState| {
860                    state.session == session
861                        && (state.job.as_deref() == Some(job.as_str()) || state.jobs.contains(&job))
862                };
863                let own = states
864                    .iter()
865                    .filter(|state| served(state))
866                    .max_by_key(|state| state.started_unix)
867                    .cloned();
868                // A queued follow-up has no run of its own. The conversation
869                // it continues does, and that run has the directory and the
870                // agent's session.
871                let fallback = if own.is_none() {
872                    continued.as_deref().and_then(|handle| {
873                        states
874                            .iter()
875                            .filter(|state| {
876                                state.session == session
877                                    && state.conversation.as_deref() == Some(handle)
878                            })
879                            .max_by_key(|state| state.started_unix)
880                            .cloned()
881                    })
882                } else {
883                    None
884                };
885                let run = own.or_else(|| fallback.clone());
886                // Adopting while the job that run served is still interrupted
887                // would start a second process in its agent session. The
888                // follow-up waits until that job is resumed, then queues
889                // behind the conversation that resume adopted.
890                let blocking = match &fallback {
891                    Some(found) if found.vendor.is_some() && found.cwd.is_dir() => {
892                        unresumed_served(&jobs, found, &job)
893                    }
894                    _ => Vec::new(),
895                };
896                let live = run.as_ref().is_some_and(|run| {
897                    delegations
898                        .list(true)
899                        .iter()
900                        .any(|entry| entry.record.handle == run.handle && entry.processes > 0)
901                });
902                (successor, run, live, blocking)
903            })
904            .await
905            .map_err(|error| error.to_string())?
906        };
907        if let Some(resumed) = successor {
908            return Err(format!("{job} was already resumed as {resumed}"));
909        }
910        if live {
911            return Err(format!(
912                "{job}'s agent ({}) still runs; SCV stops what a stopped SCV process left \
913                 within a minute (or `scv agents kill` it now). Resume it after that",
914                run.as_ref().map_or("", |run| run.handle.as_str())
915            ));
916        }
917        if !blocking.is_empty() {
918            let ahead = if blocking.len() == 1 { "it" } else { "them" };
919            return Err(format!(
920                "{job} was queued behind {}; resume {ahead} first. This follow-up then waits \
921                 behind the same conversation, so one agent session is not continued twice",
922                blocking.join(", ")
923            ));
924        }
925        let mut record = record;
926        let cwd_live = run.as_ref().is_some_and(|found| found.cwd.is_dir());
927        if let Some(found) = &run
928            && cwd_live
929        {
930            remember_cwd(&mut record, &found.cwd);
931            if record.worktree.is_none()
932                && let Some(worktree) =
933                    crate::delegate::worktree::worktree_ref(&self.worktrees, &found.cwd)
934            {
935                record.worktree = Some(worktree);
936            }
937        }
938        // The directory is gone (the owner removed the worktree; an
939        // interrupted job does not hold it). The job that run served still
940        // records the checkout, dismissed records included, which is what a
941        // new worktree is made from. The run's path still names the
942        // subdirectory inside it.
943        if record.worktree.is_none()
944            && let Some(found) = &run
945            && let Some(worktree) = served_worktree(&self.jobs, found)
946        {
947            record.worktree = Some(worktree);
948        }
949        if !cwd_live
950            && let Some(found) = &run
951            && let Some(worktree) = &record.worktree
952            && found.cwd.starts_with(Path::new(&worktree.path))
953        {
954            remember_cwd(&mut record, &found.cwd);
955        }
956        let cwd = run
957            .as_ref()
958            .map(|run| run.cwd.clone())
959            .filter(|cwd| cwd.is_dir());
960        let adopted = run
961            .as_ref()
962            .and_then(|run| run.vendor.as_deref().zip(cwd.as_deref()))
963            .and_then(|(vendor, cwd)| agent.adopt(&record.agent, cwd, vendor));
964        let mut arguments = if let Some(handle) = &adopted {
965            json!({
966                "session": handle,
967                "prompt": format!(
968                    "[SCV] SCV stopped before your last turn on this task finished (a restart \
969                     or a crash), so it was cut off. Check where the work stands (files, git \
970                     status, anything still running), then carry on. The task was:\n\n{}",
971                    record.prompt
972                ),
973            })
974        } else {
975            rerun_arguments(&record, |option| agent.takes(&record.agent, option))
976        };
977        if let Some(object) = arguments.as_object_mut() {
978            for key in ["timeout_seconds", "no_timeout"] {
979                if let Some(value) = record.arguments.get(key) {
980                    object.insert(key.into(), value.clone());
981                }
982            }
983        }
984        // The new job's record says what it resumes from its first write.
985        let meta = JobMeta {
986            resumed_from: Some(key.to_owned()),
987            worktree: record
988                .worktree
989                .clone()
990                .filter(|worktree| Path::new(&worktree.path).is_dir()),
991            ..JobMeta::default()
992        };
993        let resumed = self.start_with(host, arguments, meta).await?;
994        ledger::update(&self.jobs, session, job, |old| {
995            old.resumed_as = Some(resumed.clone());
996            if !old.state.is_final() {
997                ledger::interrupt(old, "its session ended before it finished");
998            }
999        });
1000        Ok(resumed)
1001    }
1002
1003    /// Stop job `job` of live session `session`.
1004    pub fn cancel(&self, session: &str, job: &str) -> Result<String, String> {
1005        let jobs = {
1006            let inner = lock(&self.inner);
1007            inner
1008                .sessions
1009                .get(session)
1010                .and_then(|slot| slot.jobs.as_ref())
1011                .and_then(Weak::upgrade)
1012        };
1013        let Some(jobs) = jobs else {
1014            return Err(format!(
1015                "session {session} does not run in this daemon; a job whose session ended \
1016                 stopped with it, and `scv agents kill` stops another process's runs"
1017            ));
1018        };
1019        jobs.cancel_for_owner(job)?;
1020        Ok(format!(
1021            "Stopping {job}: its agent and every process it started are stopped; work it \
1022             already wrote stays."
1023        ))
1024    }
1025
1026    /// Forget finished or interrupted job `job` of `session`.
1027    pub fn dismiss(&self, session: &str, job: &str) -> Result<String, String> {
1028        let record = ledger::load(&self.jobs, session, job)
1029            .ok_or_else(|| format!("no record of {session}/{job}"))?;
1030        let jobs = lock(&self.inner)
1031            .sessions
1032            .get(session)
1033            .and_then(|slot| slot.jobs.as_ref())
1034            .and_then(Weak::upgrade);
1035        let running = jobs.as_ref().is_some_and(|jobs| {
1036            jobs.live()
1037                .iter()
1038                .any(|live| live.id == job && live.running)
1039        });
1040        if running || (!record.state.is_final() && record.owner.is_alive()) {
1041            return Err(format!("{job} is still running; stop it first"));
1042        }
1043        // A session that has yet to report the result writes its record
1044        // again when it does: this daemon's says so itself, and one of
1045        // another live SCV process by its record.
1046        let elsewhere =
1047            record.owner.is_alive() && ProcessIdentity::current().as_ref() != Some(&record.owner);
1048        if jobs.is_some_and(|jobs| jobs.unreported(job)) || (elsewhere && !record.reported) {
1049            return Err(format!(
1050                "{job}'s result has not reached its session yet; dismiss it once it has"
1051            ));
1052        }
1053        ledger::dismiss(&self.jobs, session, job).map_err(|error| error.to_string())?;
1054        Ok(format!("Forgot {session}/{job}."))
1055    }
1056
1057    /// The ledger record of `job` in `session`.
1058    pub fn job_record(&self, session: &str, job: &str) -> Option<JobRecord> {
1059        ledger::load(&self.jobs, session, job)
1060    }
1061
1062    /// Job `job` of `session` as the workbench lists it, with its reply or
1063    /// error once it has ended, each cut to [`MAX_JOB_RESULT_BYTES`].
1064    pub fn job_result(&self, session: &str, job: &str) -> Result<JobResult, String> {
1065        let record = ledger::load(&self.jobs, session, job)
1066            .ok_or_else(|| format!("no record of {session}/{job}"))?;
1067        let (jobs, awaiting, session_live) = {
1068            let inner = lock(&self.inner);
1069            let slot = inner.sessions.get(session);
1070            (
1071                slot.and_then(|slot| slot.jobs.as_ref())
1072                    .and_then(Weak::upgrade),
1073                inner
1074                    .approvals
1075                    .values()
1076                    .find(|pending| pending.info.session == session && pending.info.job == job)
1077                    .map(|pending| pending.info.clone()),
1078                slot.is_some(),
1079            )
1080        };
1081        let live = jobs
1082            .as_ref()
1083            .and_then(|jobs| jobs.live().into_iter().find(|live| live.id == job));
1084        // The run that served it last, chosen as the snapshot chooses it.
1085        let run = activity::list_states(&self.runs)
1086            .into_iter()
1087            .filter(|state| {
1088                state.session == session
1089                    && (state.job.as_deref() == Some(job)
1090                        || state.jobs.iter().any(|served| served == job))
1091            })
1092            .min_by_key(|state| std::cmp::Reverse((state.last_activity_unix, state.started_unix)));
1093        let (state, detail) = job_state(
1094            &record,
1095            live.as_ref(),
1096            run.as_ref(),
1097            awaiting.as_ref(),
1098            session_live,
1099            unix_now(),
1100        );
1101        let mut truncated = false;
1102        let mut cut = |text: Option<String>| {
1103            text.map(|text| {
1104                let kept = scv_client::text::utf8_prefix(&text, MAX_JOB_RESULT_BYTES);
1105                truncated |= kept.len() < text.len();
1106                kept.to_owned()
1107            })
1108        };
1109        let reply = cut(record.reply);
1110        let error = cut(record.error);
1111        Ok(JobResult {
1112            session: record.session,
1113            job: record.job,
1114            agent: record.agent,
1115            state,
1116            detail,
1117            reply,
1118            error,
1119            truncated,
1120            outcome: jobs.and_then(|jobs| jobs.outcome(job)),
1121            finished_unix_seconds: record.finished_unix,
1122        })
1123    }
1124
1125    /// Take conversation `conversation` of live session `session` over in
1126    /// its agent's own terminal for the owner, or attach to the takeover of
1127    /// it already running, and return the whole screen. A second client's
1128    /// attach takes the terminal from the first, whose hold stops working.
1129    pub async fn attach_terminal(
1130        &self,
1131        session: &str,
1132        conversation: &str,
1133        cols: Option<u16>,
1134        rows: Option<u16>,
1135    ) -> Result<AttachPage, String> {
1136        self.take_terminal(session, conversation, cols, rows, false, None)
1137            .await
1138            .map(|(page, _)| page)
1139    }
1140
1141    /// [`Self::attach_terminal`], stopping first the background job that
1142    /// runs the conversation's turn. The conversation's lane is held for
1143    /// the takeover before that job is stopped, so no other prompt starts a
1144    /// turn in between, and nothing is stopped when it cannot be held: when
1145    /// the agent's terminal is not offered, a takeover of it runs or is
1146    /// starting, a reviewed job holds it, it has no agent session yet, the
1147    /// running turn is no background job, that job waits for the owner's
1148    /// approval (which a takeover never answers or withdraws), or as many
1149    /// takeovers as may run do. Given the `job` the owner was told it stops,
1150    /// nothing is stopped either when another job runs the turn by then. A
1151    /// conversation running no turn is taken over as by `attach_terminal`.
1152    /// Also returns SCV's note naming the job it stopped.
1153    pub async fn interrupt_and_attach(
1154        &self,
1155        session: &str,
1156        conversation: &str,
1157        cols: Option<u16>,
1158        rows: Option<u16>,
1159        job: Option<&str>,
1160    ) -> Result<(AttachPage, Option<String>), String> {
1161        self.take_terminal(session, conversation, cols, rows, true, job)
1162            .await
1163    }
1164
1165    async fn take_terminal(
1166        &self,
1167        session: &str,
1168        conversation: &str,
1169        cols: Option<u16>,
1170        rows: Option<u16>,
1171        interrupt: bool,
1172        named: Option<&str>,
1173    ) -> Result<(AttachPage, Option<String>), String> {
1174        self.expire_takeovers();
1175        let key = format!("{session}/{conversation}");
1176        let id = format!("at-{}", &uuid::Uuid::new_v4().simple().to_string()[..16]);
1177        if let Some(page) = self.attach_running(&key, &id, cols, rows) {
1178            return Ok((page, None));
1179        }
1180        let _starting = self.attaching.lock().await;
1181        // It may have started while this one waited.
1182        if let Some(page) = self.attach_running(&key, &id, cols, rows) {
1183            return Ok((page, None));
1184        }
1185        if self.closing.load(Ordering::Acquire) {
1186            return Err(stopping());
1187        }
1188        if self.running_takeovers() >= MAX_TAKEOVERS {
1189            return Err(at_cap());
1190        }
1191        let (agent, _) = self.agent(session)?;
1192        let size = takeover::clamp_size(cols, rows);
1193        let (takeover, stopped) = if interrupt {
1194            // Held before anything is stopped; refused, nothing is.
1195            let reserved = agent.reserve_takeover(conversation)?;
1196            if let (Some(job), Some(named)) = (&reserved.job, named)
1197                && job != named
1198            {
1199                return Err(not_the_named_job(conversation, named, job));
1200            }
1201            if let Some(job) = &reserved.job
1202                && let Some(approval) = lock(&self.inner)
1203                    .approvals
1204                    .values()
1205                    .find(|pending| pending.info.session == session && &pending.info.job == job)
1206            {
1207                return Err(awaits_approval(job, &approval.info.id));
1208            }
1209            let stopped = reserved.job.clone();
1210            (
1211                agent.take_over_reserved(reserved, session, size).await?,
1212                stopped,
1213            )
1214        } else {
1215            (agent.take_over(conversation, session, size).await?, None)
1216        };
1217        let note = stopped.map(|job| format!("Stopped {job} and took {conversation} over."));
1218        let page = takeover.page(&id, 0);
1219        {
1220            let mut takeovers = lock(&self.takeovers);
1221            if !self.closing.load(Ordering::Acquire) {
1222                takeovers.insert(
1223                    key,
1224                    Held {
1225                        takeover,
1226                        lease: Some((id, Instant::now())),
1227                        closed: None,
1228                    },
1229                );
1230                return Ok((page, note));
1231            }
1232        }
1233        // SCV began stopping while it started: it ends like the others.
1234        takeover.end().await;
1235        Err(stopping())
1236    }
1237
1238    /// Attach client `id` to the takeover of `key` if it runs, sized `cols`
1239    /// by `rows`, and return the whole screen; forget one that closed.
1240    fn attach_running(
1241        &self,
1242        key: &str,
1243        id: &str,
1244        cols: Option<u16>,
1245        rows: Option<u16>,
1246    ) -> Option<AttachPage> {
1247        let (takeover, watched) = {
1248            let mut takeovers = lock(&self.takeovers);
1249            match takeovers.get_mut(key) {
1250                Some(held) if !held.takeover.is_finished() => {
1251                    let watched = held.lease.is_some();
1252                    held.lease = Some((id.to_owned(), Instant::now()));
1253                    (Arc::clone(&held.takeover), watched)
1254                }
1255                Some(_) => {
1256                    takeovers.remove(key);
1257                    return None;
1258                }
1259                None => return None,
1260            }
1261        };
1262        if !watched {
1263            takeover.note_attached(true);
1264        }
1265        takeover.resize(cols, rows);
1266        Some(takeover.page(id, 0))
1267    }
1268
1269    /// Type `input` into the terminal client `id` is attached to, resize it,
1270    /// and return what changed on its screen since generation `after`.
1271    pub async fn attach_io(
1272        &self,
1273        id: &str,
1274        after: u64,
1275        input: &str,
1276        cols: Option<u16>,
1277        rows: Option<u16>,
1278    ) -> Result<AttachPage, String> {
1279        if input.len() > takeover::MAX_INPUT_BYTES {
1280            return Err(format!(
1281                "at most {} KiB can be typed at once",
1282                takeover::MAX_INPUT_BYTES / 1024
1283            ));
1284        }
1285        self.expire_takeovers();
1286        let takeover = self.leased(id)?;
1287        let note = takeover.input(input.as_bytes()).await;
1288        takeover.resize(cols, rows);
1289        let mut page = takeover.page(id, after);
1290        page.note = note;
1291        Ok(page)
1292    }
1293
1294    /// Stop client `id` watching its terminal; the agent keeps running.
1295    pub fn detach(&self, id: &str) -> Result<String, String> {
1296        let takeover = {
1297            let mut takeovers = lock(&self.takeovers);
1298            let held = takeovers
1299                .values_mut()
1300                .find(|held| held.lease.as_ref().is_some_and(|(lease, _)| lease == id))
1301                .ok_or_else(not_attached)?;
1302            held.lease = None;
1303            Arc::clone(&held.takeover)
1304        };
1305        takeover.note_attached(false);
1306        Ok(if takeover.is_running() {
1307            format!(
1308                "Detached from {}; it keeps running in its terminal.",
1309                takeover.conversation
1310            )
1311        } else {
1312            format!(
1313                "Detached from {}, whose terminal has closed.",
1314                takeover.conversation
1315            )
1316        })
1317    }
1318
1319    /// End the takeover of `conversation` of `session`: hang its terminal
1320    /// up, stop its process, and give the conversation back to SCV's
1321    /// turns. Returns SCV's note and the final screen.
1322    pub async fn end_takeover(
1323        &self,
1324        session: &str,
1325        conversation: &str,
1326    ) -> Result<(String, AttachPage), String> {
1327        let key = format!("{session}/{conversation}");
1328        let held = lock(&self.takeovers)
1329            .remove(&key)
1330            .ok_or_else(|| format!("{conversation} of {session} is not taken over"))?;
1331        held.takeover.end().await;
1332        let id = held.lease.map(|(id, _)| id).unwrap_or_default();
1333        let page = held.takeover.page(&id, 0);
1334        let how = page.exit.clone().unwrap_or_else(|| "closed".into());
1335        Ok((
1336            format!("Ended the takeover of {conversation}: the terminal closed ({how})."),
1337            page,
1338        ))
1339    }
1340
1341    /// End every takeover, waiting for each: the daemon is stopping, so
1342    /// none starts any more.
1343    pub async fn end_takeovers(&self) {
1344        let held: Vec<Held> = {
1345            let mut takeovers = lock(&self.takeovers);
1346            self.closing.store(true, Ordering::Release);
1347            takeovers.drain().map(|(_, held)| held).collect()
1348        };
1349        futures_util::future::join_all(held.iter().map(|held| held.takeover.end())).await;
1350    }
1351
1352    /// How many takeovers run.
1353    fn running_takeovers(&self) -> usize {
1354        lock(&self.takeovers)
1355            .values()
1356            .filter(|held| !held.takeover.is_finished())
1357            .count()
1358    }
1359
1360    /// Sessions with a takeover still running, which count as in use.
1361    pub fn takeover_sessions(&self) -> HashSet<String> {
1362        lock(&self.takeovers)
1363            .values()
1364            .filter(|held| !held.takeover.is_finished())
1365            .map(|held| held.takeover.session.clone())
1366            .collect()
1367    }
1368
1369    /// End the takeovers of `session` (every one with `None`) in the
1370    /// background: their session is gone.
1371    fn end_takeovers_of(&self, session: Option<&str>) {
1372        let ending: Vec<Arc<Takeover>> = {
1373            let mut takeovers = lock(&self.takeovers);
1374            let keys: Vec<String> = takeovers
1375                .iter()
1376                .filter(|(_, held)| session.is_none_or(|session| held.takeover.session == session))
1377                .map(|(key, _)| key.clone())
1378                .collect();
1379            keys.iter()
1380                .filter_map(|key| takeovers.remove(key))
1381                .map(|held| held.takeover)
1382                .collect()
1383        };
1384        if ending.is_empty() {
1385            return;
1386        }
1387        match tokio::runtime::Handle::try_current() {
1388            Ok(runtime) => {
1389                runtime.spawn(async move {
1390                    futures_util::future::join_all(ending.iter().map(|takeover| takeover.end()))
1391                        .await;
1392                });
1393            }
1394            Err(_) => ending.iter().for_each(|takeover| takeover.kill_now()),
1395        }
1396    }
1397
1398    /// The running or recently closed takeover client `id` holds.
1399    fn leased(&self, id: &str) -> Result<Arc<Takeover>, String> {
1400        let mut takeovers = lock(&self.takeovers);
1401        let held = takeovers
1402            .values_mut()
1403            .find(|held| held.lease.as_ref().is_some_and(|(lease, _)| lease == id))
1404            .ok_or_else(not_attached)?;
1405        if let Some((_, read)) = &mut held.lease {
1406            *read = Instant::now();
1407        }
1408        Ok(Arc::clone(&held.takeover))
1409    }
1410
1411    /// Detach clients that stopped reading, and forget takeovers whose
1412    /// terminal closed a while ago.
1413    fn expire_takeovers(&self) {
1414        let now = Instant::now();
1415        let mut detached = Vec::new();
1416        let mut takeovers = lock(&self.takeovers);
1417        takeovers.retain(|_, held| {
1418            if held.takeover.is_finished() {
1419                let closed = *held.closed.get_or_insert(now);
1420                return now.duration_since(closed) < CLOSED_KEPT;
1421            }
1422            if held
1423                .lease
1424                .as_ref()
1425                .is_some_and(|(_, read)| now.duration_since(*read) >= ATTACH_LEASE)
1426            {
1427                held.lease = None;
1428                detached.push(Arc::clone(&held.takeover));
1429            }
1430            true
1431        });
1432        drop(takeovers);
1433        for takeover in detached {
1434            takeover.note_attached(false);
1435        }
1436    }
1437
1438    /// Settle what processes that stopped left unfinished, and remove old
1439    /// records: the daemon runs this at startup and every minute.
1440    pub fn maintain(&self) -> Maintenance {
1441        self.expire_takeovers();
1442        let interrupted_jobs = ledger::settle_orphans(&self.jobs)
1443            .into_iter()
1444            .map(|record| format!("{}/{}", record.session, record.job))
1445            .collect();
1446        let interrupted_runs = activity::settle_orphans(&self.runs);
1447        let pruned = ledger::prune(&self.jobs, KEEP) + activity::prune(&self.runs, KEEP, MAX_RUNS);
1448        Maintenance {
1449            interrupted_jobs,
1450            interrupted_runs,
1451            pruned,
1452        }
1453    }
1454}
1455
1456fn stopping() -> String {
1457    "SCV is stopping, so it takes no conversation over now".into()
1458}
1459
1460fn at_cap() -> String {
1461    format!("{MAX_TAKEOVERS} conversations are taken over already; end one first")
1462}
1463
1464/// The refusal to stop `job`, which waits for the owner's answer to
1465/// approval request `id`, for a takeover.
1466fn awaits_approval(job: &str, id: &str) -> String {
1467    format!(
1468        "{job} waits for your approval ({id}); answer it first (`scv jobs approve` or `deny`). \
1469         Taking its conversation over would withdraw the request, and a takeover never answers \
1470         or withdraws an approval"
1471    )
1472}
1473
1474/// The refusal to stop `running`, which runs conversation `conversation`'s
1475/// turn, for a takeover the owner was told stops `named`.
1476fn not_the_named_job(conversation: &str, named: &str, running: &str) -> String {
1477    format!(
1478        "{conversation} is running {running}'s turn now, not {named}'s, so nothing was stopped; \
1479         attach again to stop {running}"
1480    )
1481}
1482
1483/// `offer` as `attach` would answer it now, with what only the workbench
1484/// knows: with `running` takeovers at the cap, none starts, and stopping a
1485/// job that waits for the owner's answer to `approval` is refused.
1486fn settle_offer(mut offer: AttachOffer, approval: Option<&str>, running: usize) -> AttachOffer {
1487    if offer.interrupt
1488        && let (Some(job), Some(id)) = (&offer.job, approval)
1489    {
1490        offer.detail = Some(awaits_approval(job, id));
1491        offer.interrupt = false;
1492    }
1493    let starts = offer.state == AttachState::Ready || offer.interrupt;
1494    if starts && running >= MAX_TAKEOVERS {
1495        offer.state = AttachState::Full;
1496        offer.detail = Some(at_cap());
1497        offer.interrupt = false;
1498    }
1499    offer
1500}
1501
1502fn not_attached() -> String {
1503    "this terminal is not attached here any more (another dashboard attached to it, or its \
1504     takeover ended); attach again"
1505        .into()
1506}
1507
1508/// A job claimed for resuming, released when dropped.
1509struct Claim<'a> {
1510    claims: &'a Mutex<HashSet<String>>,
1511    key: &'a str,
1512}
1513
1514impl Drop for Claim<'_> {
1515    fn drop(&mut self) {
1516        lock(self.claims).remove(self.key);
1517    }
1518}
1519
1520struct SlotView {
1521    id: String,
1522    label: String,
1523    workspace: PathBuf,
1524    state: AgentState,
1525    detail: Option<String>,
1526    agent: Option<Arc<BackgroundCapable>>,
1527    jobs: Option<Arc<BackgroundJobs>>,
1528}
1529
1530/// What a job is doing, from its record, its live entry when its session
1531/// runs here, the run serving it, and an approval it waits on.
1532fn job_state(
1533    record: &JobRecord,
1534    live: Option<&LiveJob>,
1535    run: Option<&RunState>,
1536    awaiting: Option<&WorkbenchApproval>,
1537    session_live: bool,
1538    now: u64,
1539) -> (AgentState, Option<String>) {
1540    if record.state.is_final() {
1541        let detail = match record.state {
1542            AgentState::Interrupted => record.interrupted.clone(),
1543            AgentState::Completed => None,
1544            _ => record
1545                .error
1546                .as_deref()
1547                .and_then(|error| error.lines().find(|line| !line.trim().is_empty()))
1548                .map(|line| bounded(line, 200)),
1549        };
1550        return (record.state, detail);
1551    }
1552    if let Some(approval) = awaiting {
1553        return (
1554            AgentState::AwaitingInput,
1555            Some(format!(
1556                "waiting for your approval: {}",
1557                bounded(&approval.summary, 200)
1558            )),
1559        );
1560    }
1561    if let Some(live) = live {
1562        if live.waiting {
1563            let behind = record
1564                .arguments
1565                .get("session")
1566                .and_then(Value::as_str)
1567                .map_or_else(String::new, |handle| format!(" {handle}'s"));
1568            return (
1569                AgentState::Queued,
1570                Some(format!("waits for conversation{behind} running turn")),
1571            );
1572        }
1573        if live.running {
1574            if let Some(run) = run.filter(|run| !run.ended) {
1575                return activity::current(run, now);
1576            }
1577            return (
1578                AgentState::Working,
1579                live.progress
1580                    .clone()
1581                    .or_else(|| Some(format!("running for {} s", live.elapsed.as_secs()))),
1582            );
1583        }
1584    }
1585    if !record.owner.is_alive() {
1586        return (
1587            AgentState::Interrupted,
1588            Some("the SCV process that ran it stopped".into()),
1589        );
1590    }
1591    if record.owner.pid == std::process::id() && !session_live {
1592        return (
1593            AgentState::Interrupted,
1594            Some("its session ended before it finished".into()),
1595        );
1596    }
1597    match run {
1598        Some(run) if !run.ended => activity::current(run, now),
1599        _ => (record.state, None),
1600    }
1601}
1602
1603/// The workbench's own session ID for `workspace`: stable, so the owner's
1604/// jobs in one workspace group under one session across daemon restarts.
1605pub fn host_session(workspace: &Path) -> String {
1606    use sha2::{Digest, Sha256};
1607    let digest = Sha256::digest(workspace.as_os_str().as_encoded_bytes());
1608    let hex: String = digest[..8]
1609        .iter()
1610        .map(|byte| format!("{byte:02x}"))
1611        .collect();
1612    format!("workbench-{hex}")
1613}
1614
1615/// `<session>/<job>` of a job an `agent` call started.
1616fn job_name(session: &str, started: &Value) -> String {
1617    format!(
1618        "{session}/{}",
1619        started.get("job").and_then(Value::as_str).unwrap_or("job")
1620    )
1621}
1622
1623/// Decides a background job's approval request: the session's unattended
1624/// answer, or, for one it would deny while the owner watches, the owner's.
1625struct AttendedGate {
1626    workbench: Weak<Workbench>,
1627    session: String,
1628    unattended: Arc<dyn ApprovalGate>,
1629}
1630
1631#[async_trait]
1632impl ApprovalGate for AttendedGate {
1633    async fn approve(
1634        &self,
1635        request: ApprovalRequest,
1636        cancellation: CancellationToken,
1637    ) -> Result<bool, AgentError> {
1638        if self
1639            .unattended
1640            .approve(request.clone(), cancellation.clone())
1641            .await?
1642        {
1643            return Ok(true);
1644        }
1645        match self.workbench.upgrade() {
1646            Some(workbench) if workbench.watched() => {
1647                workbench.ask(&self.session, request, &cancellation).await
1648            }
1649            _ => Ok(false),
1650        }
1651    }
1652}
1653
1654fn bounded(text: &str, max: usize) -> String {
1655    if text.chars().count() <= max {
1656        return text.to_owned();
1657    }
1658    let mut cut: String = text.chars().take(max.saturating_sub(1)).collect();
1659    cut.push('…');
1660    cut
1661}
1662
1663fn unix_now() -> u64 {
1664    SystemTime::now()
1665        .duration_since(UNIX_EPOCH)
1666        .map_or(0, |elapsed| elapsed.as_secs())
1667}
1668
1669/// Jobs whose turns `run` served, the latest one first.
1670fn served_job_names(run: &RunState) -> Vec<&str> {
1671    let mut names = Vec::new();
1672    if let Some(job) = run.job.as_deref() {
1673        names.push(job);
1674    }
1675    for job in &run.jobs {
1676        if !names.contains(&job.as_str()) {
1677            names.push(job.as_str());
1678        }
1679    }
1680    names
1681}
1682
1683/// Interrupted jobs `run` served that the owner has not resumed, as
1684/// `session/job`. `skip` is the follow-up being resumed.
1685fn unresumed_served(root: &Path, run: &RunState, skip: &str) -> Vec<String> {
1686    served_job_names(run)
1687        .into_iter()
1688        .filter(|name| *name != skip)
1689        .filter_map(|name| {
1690            let record = ledger::load(root, &run.session, name)?;
1691            record
1692                .resumable()
1693                .then(|| format!("{}/{}", run.session, name))
1694        })
1695        .collect()
1696}
1697
1698/// The worktree recorded on a job `run` served. A follow-up has none of
1699/// its own, and the run's directory may already have been removed. A
1700/// dismissed record still carries the worktree.
1701fn served_worktree(root: &Path, run: &RunState) -> Option<scv_protocol::WorktreeRef> {
1702    served_job_names(run).into_iter().find_map(|name| {
1703        ledger::load_including_dismissed(root, &run.session, name)
1704            .and_then(|record| record.worktree)
1705    })
1706}
1707
1708/// Record `cwd` on `record` when the call did not name one.
1709fn remember_cwd(record: &mut ledger::JobRecord, cwd: &Path) {
1710    let has_cwd = record
1711        .arguments
1712        .get("cwd")
1713        .and_then(Value::as_str)
1714        .is_some_and(|cwd| !cwd.is_empty());
1715    if !has_cwd && let Some(object) = record.arguments.as_object_mut() {
1716        object.insert("cwd".into(), cwd.display().to_string().into());
1717    }
1718}
1719
1720/// The `agent` call that runs interrupted job `record` again from its
1721/// prompt in a new conversation: its options but those the host agent does
1722/// not `take`, and a note that an earlier attempt may have left changes. A
1723/// job that ran in a worktree runs in the same directory of it while the
1724/// worktree is there, and in a new worktree of its repository once it was
1725/// removed, so its work never lands in the repository's own checkout.
1726fn rerun_arguments(record: &JobRecord, takes: impl Fn(&str) -> bool) -> Value {
1727    let mut arguments = record.arguments.clone();
1728    if let Some(object) = arguments.as_object_mut() {
1729        object.remove("session");
1730        object.remove("worktree");
1731        object.remove("on_busy");
1732        // What this session's agent does not take is left to its own
1733        // defaults.
1734        for option in ["model", "effort"] {
1735            if !takes(option) {
1736                object.remove(option);
1737            }
1738        }
1739        object.insert("agent".into(), record.agent.clone().into());
1740        object.insert(
1741            "prompt".into(),
1742            format!(
1743                "[SCV] An earlier attempt at this task was cut off when SCV stopped (a restart \
1744                 or a crash), and its conversation could not be resumed. It may have left \
1745                 partial changes here: check them first, then do the task:\n\n{}",
1746                record.prompt
1747            )
1748            .into(),
1749        );
1750        if let Some(worktree) = &record.worktree {
1751            let top = Path::new(&worktree.path);
1752            // The call's directory inside the worktree, as it was recorded.
1753            let inside = object
1754                .get("cwd")
1755                .and_then(Value::as_str)
1756                .and_then(|cwd| Path::new(cwd).strip_prefix(top).ok())
1757                .map(Path::to_path_buf)
1758                .unwrap_or_default();
1759            if top.is_dir() {
1760                let cwd = Some(top.join(&inside))
1761                    .filter(|cwd| cwd.is_dir())
1762                    .unwrap_or_else(|| top.to_owned());
1763                object.insert("cwd".into(), cwd.display().to_string().into());
1764            } else {
1765                let repo = Path::new(&worktree.repo);
1766                let cwd = Some(repo.join(&inside))
1767                    .filter(|cwd| cwd.is_dir())
1768                    .unwrap_or_else(|| repo.to_owned());
1769                object.insert("cwd".into(), cwd.display().to_string().into());
1770                object.insert("worktree".into(), true.into());
1771            }
1772        }
1773    }
1774    arguments
1775}
1776
1777#[cfg(test)]
1778mod tests;