1use 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
61pub const ATTENDED_WAIT: Duration = Duration::from_secs(120);
64pub const WATCH_TTL: Duration = Duration::from_secs(15);
66const MAX_LISTED: usize = 200;
69pub const KEEP: Duration = Duration::from_secs(7 * 24 * 3600);
71pub const MAX_RUNS: usize = 500;
73pub const ATTACH_LEASE: Duration = Duration::from_secs(30);
77const CLOSED_KEPT: Duration = Duration::from_secs(60);
80const MAX_TAKEOVERS: usize = 8;
82
83pub 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 resuming: Mutex<HashSet<String>>,
93 takeovers: Mutex<HashMap<String, Held>>,
96 attaching: tokio::sync::Mutex<()>,
99 closing: AtomicBool,
102}
103
104struct Held {
106 takeover: Arc<Takeover>,
107 lease: Option<(String, Instant)>,
109 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
126struct 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
142pub 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 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 workbench.end_takeovers_of(Some(&self.id));
190 }
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum Sent {
197 Job(String),
199 Steered,
201}
202
203#[derive(Debug, Clone, Default, PartialEq, Eq)]
205pub struct Maintenance {
206 pub interrupted_jobs: Vec<String>,
208 pub interrupted_runs: Vec<String>,
210 pub pruned: usize,
212}
213
214impl Workbench {
215 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 pub fn jobs_dir(&self) -> &Path {
234 &self.jobs
235 }
236
237 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn job_record(&self, session: &str, job: &str) -> Option<JobRecord> {
1030 ledger::load(&self.jobs, session, job)
1031 }
1032
1033 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 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 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 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 takeover.end().await;
1151 Err(stopping())
1152 }
1153
1154 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 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 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 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 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 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 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 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 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 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
1374struct 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
1396fn 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
1469pub 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
1481fn 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
1489struct 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
1535fn 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
1549fn 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
1564fn 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
1574fn 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
1586fn 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 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 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;