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