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