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