1use std::{
23 collections::{BTreeMap, HashMap, HashSet},
24 path::{Path, PathBuf},
25 sync::{Arc, Mutex, Weak},
26 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
27};
28
29use async_trait::async_trait;
30use scv_client::Layout;
31use scv_core::{AgentError, ApprovalGate, ApprovalRequest};
32use scv_protocol::{
33 AgentState, RunLogPage, SendMode, WorkbenchApproval, WorkbenchJob, WorkbenchRun,
34 WorkbenchSession, WorkbenchSnapshot,
35};
36use serde_json::{Value, json};
37use tokio::sync::oneshot;
38use tokio_util::sync::CancellationToken;
39
40use crate::{
41 delegate::{
42 activity::{self, RunState},
43 background::{
44 BackgroundCapable, BackgroundJobs, JobMeta, LiveJob, OwnerCall,
45 ledger::{self, JobRecord},
46 },
47 records::{DelegationRegistry, ProcessIdentity},
48 },
49 sync::lock,
50};
51
52pub const ATTENDED_WAIT: Duration = Duration::from_secs(120);
55pub const WATCH_TTL: Duration = Duration::from_secs(15);
57const MAX_LISTED: usize = 200;
60pub const KEEP: Duration = Duration::from_secs(7 * 24 * 3600);
62pub const MAX_RUNS: usize = 500;
64
65pub struct Workbench {
67 runs: PathBuf,
68 jobs: PathBuf,
69 worktrees: PathBuf,
70 delegations: Arc<DelegationRegistry>,
71 inner: Mutex<Inner>,
72 watched_until: Mutex<Option<Instant>>,
73 resuming: Mutex<HashSet<String>>,
75}
76
77impl std::fmt::Debug for Workbench {
78 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 formatter.debug_struct("Workbench").finish_non_exhaustive()
80 }
81}
82
83#[derive(Default)]
84struct Inner {
85 sessions: HashMap<String, Slot>,
86 approvals: BTreeMap<String, Pending>,
87 next: u64,
88}
89
90struct Slot {
92 token: u64,
93 label: String,
94 workspace: PathBuf,
95 state: AgentState,
96 detail: Option<String>,
97 agent: Option<Weak<BackgroundCapable>>,
98 jobs: Option<Weak<BackgroundJobs>>,
99}
100
101struct Pending {
102 info: WorkbenchApproval,
103 answer: Option<oneshot::Sender<bool>>,
104}
105
106pub struct SessionHandle {
109 workbench: Weak<Workbench>,
110 id: String,
111 token: u64,
112}
113
114impl std::fmt::Debug for SessionHandle {
115 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 formatter
117 .debug_struct("SessionHandle")
118 .field("id", &self.id)
119 .finish()
120 }
121}
122
123impl SessionHandle {
124 pub fn set_state(&self, state: AgentState, detail: Option<String>) {
127 let Some(workbench) = self.workbench.upgrade() else {
128 return;
129 };
130 if let Some(slot) = lock(&workbench.inner).sessions.get_mut(&self.id)
131 && slot.token == self.token
132 {
133 slot.state = state;
134 slot.detail = detail;
135 }
136 }
137}
138
139impl Drop for SessionHandle {
140 fn drop(&mut self) {
141 let Some(workbench) = self.workbench.upgrade() else {
142 return;
143 };
144 let mut inner = lock(&workbench.inner);
145 if inner
146 .sessions
147 .get(&self.id)
148 .is_some_and(|slot| slot.token == self.token)
149 {
150 inner.sessions.remove(&self.id);
151 }
152 }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
157pub enum Sent {
158 Job(String),
160 Steered,
162}
163
164#[derive(Debug, Clone, Default, PartialEq, Eq)]
166pub struct Maintenance {
167 pub interrupted_jobs: Vec<String>,
169 pub interrupted_runs: Vec<String>,
171 pub pruned: usize,
173}
174
175impl Workbench {
176 pub fn new(layout: &Layout, delegations: Arc<DelegationRegistry>) -> Arc<Self> {
179 Arc::new(Self {
180 runs: layout.runs(),
181 jobs: layout.jobs(),
182 worktrees: layout.worktrees(),
183 delegations,
184 inner: Mutex::default(),
185 watched_until: Mutex::new(None),
186 resuming: Mutex::default(),
187 })
188 }
189
190 pub fn jobs_dir(&self) -> &Path {
192 &self.jobs
193 }
194
195 pub fn register_session(
197 self: &Arc<Self>,
198 id: &str,
199 label: &str,
200 workspace: &Path,
201 ) -> SessionHandle {
202 let mut inner = lock(&self.inner);
203 inner.next += 1;
204 let token = inner.next;
205 inner.sessions.insert(
206 id.to_owned(),
207 Slot {
208 token,
209 label: label.to_owned(),
210 workspace: workspace.to_owned(),
211 state: AgentState::Idle,
212 detail: None,
213 agent: None,
214 jobs: None,
215 },
216 );
217 SessionHandle {
218 workbench: Arc::downgrade(self),
219 id: id.to_owned(),
220 token,
221 }
222 }
223
224 pub(crate) fn attach(
226 &self,
227 session: &str,
228 agent: Weak<BackgroundCapable>,
229 jobs: Weak<BackgroundJobs>,
230 ) {
231 if let Some(slot) = lock(&self.inner).sessions.get_mut(session) {
232 slot.agent = Some(agent);
233 slot.jobs = Some(jobs);
234 }
235 }
236
237 pub fn watch(&self) {
240 *lock(&self.watched_until) = Some(Instant::now() + WATCH_TTL);
241 }
242
243 fn watched(&self) -> bool {
244 lock(&self.watched_until).is_some_and(|until| Instant::now() < until)
245 }
246
247 pub fn attended_gate(
252 self: &Arc<Self>,
253 session: &str,
254 unattended: Arc<dyn ApprovalGate>,
255 ) -> Arc<dyn ApprovalGate> {
256 Arc::new(AttendedGate {
257 workbench: Arc::downgrade(self),
258 session: session.to_owned(),
259 unattended,
260 })
261 }
262
263 async fn ask(
265 &self,
266 session: &str,
267 request: ApprovalRequest,
268 cancellation: &CancellationToken,
269 ) -> Result<bool, AgentError> {
270 let (sender, receiver) = oneshot::channel();
271 let now = unix_now();
272 let id = format!("ap-{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
273 lock(&self.inner).approvals.insert(
274 id.clone(),
275 Pending {
276 info: WorkbenchApproval {
277 id: id.clone(),
278 session: session.to_owned(),
279 job: request.call_id.clone(),
280 name: request.name.clone(),
281 risk: request.risk.as_str().to_owned(),
282 cwd: request.cwd.display().to_string(),
283 summary: request.summary.clone(),
284 since_unix_seconds: now,
285 expires_unix_seconds: now + ATTENDED_WAIT.as_secs(),
286 },
287 answer: Some(sender),
288 },
289 );
290 let mut receiver = receiver;
294 let answer = tokio::select! {
295 biased;
296 answer = &mut receiver => Ok(answer.unwrap_or(false)),
297 () = tokio::time::sleep(ATTENDED_WAIT) => {
298 receiver.close();
299 Ok(receiver.try_recv().unwrap_or(false))
300 }
301 () = cancellation.cancelled() => {
302 receiver.close();
303 match receiver.try_recv() {
304 Ok(approved) => Ok(approved),
305 Err(_) => Err(AgentError::Cancelled),
306 }
307 }
308 };
309 lock(&self.inner).approvals.remove(&id);
310 answer
311 }
312
313 pub fn approve(&self, id: &str, approved: bool) -> Result<String, String> {
315 let pending = lock(&self.inner).approvals.get_mut(id).and_then(|pending| {
316 pending
317 .answer
318 .take()
319 .map(|answer| (answer, pending.info.clone()))
320 });
321 let Some((answer, info)) = pending else {
322 return Err(format!(
323 "no approval request {id:?} waits; it may have been answered or timed out"
324 ));
325 };
326 if answer.send(approved).is_err() {
327 return Err(format!("{} no longer waits for this answer", info.job));
328 }
329 Ok(format!(
330 "{} {}: {}",
331 if approved { "Approved" } else { "Denied" },
332 info.job,
333 bounded(&info.summary, 120)
334 ))
335 }
336
337 pub fn snapshot(&self) -> WorkbenchSnapshot {
340 let now = unix_now();
341 let live_handles = self.live_handles();
342 let (slots, approvals): (Vec<_>, Vec<_>) = {
343 let inner = lock(&self.inner);
344 (
345 inner
346 .sessions
347 .iter()
348 .map(|(id, slot)| SlotView {
349 id: id.clone(),
350 label: slot.label.clone(),
351 workspace: slot.workspace.clone(),
352 state: slot.state,
353 detail: slot.detail.clone(),
354 agent: slot.agent.as_ref().and_then(Weak::upgrade),
355 jobs: slot.jobs.as_ref().and_then(Weak::upgrade),
356 })
357 .collect(),
358 inner
359 .approvals
360 .values()
361 .map(|pending| pending.info.clone())
362 .collect(),
363 )
364 };
365 let live_jobs: HashMap<(String, String), LiveJob> = slots
366 .iter()
367 .filter_map(|slot| {
368 slot.jobs
369 .as_ref()
370 .map(|jobs| (slot.id.clone(), jobs.live()))
371 })
372 .flat_map(|(session, jobs)| {
373 jobs.into_iter()
374 .map(move |job| ((session.clone(), job.id.clone()), job))
375 })
376 .collect();
377 let live_sessions: HashSet<&str> = slots.iter().map(|slot| slot.id.as_str()).collect();
378 let awaiting: HashMap<(String, String), &WorkbenchApproval> = approvals
379 .iter()
380 .map(|approval| ((approval.session.clone(), approval.job.clone()), approval))
381 .collect();
382
383 let me = ProcessIdentity::current();
390 let held = |owner: &ProcessIdentity, session: &str, handle: &str| {
391 me.as_ref() == Some(owner)
392 && slots.iter().any(|slot| {
393 slot.id == session
394 && slot.agent.as_ref().is_some_and(|agent| agent.knows(handle))
395 })
396 };
397 let mut states = activity::list_states(&self.runs);
398 states
399 .sort_by_key(|state| std::cmp::Reverse((state.last_activity_unix, state.started_unix)));
400 let mut runs: Vec<WorkbenchRun> = Vec::new();
401 let mut job_runs: HashMap<(String, String), &RunState> = HashMap::new();
402 for state in &states {
403 for job in state.jobs.iter().chain(&state.job) {
404 job_runs
405 .entry((state.session.clone(), job.clone()))
406 .or_insert(state);
407 }
408 }
409 let mut ended_listed = 0;
410 for state in &states {
411 if state.ended {
412 if ended_listed >= MAX_LISTED {
413 continue;
414 }
415 ended_listed += 1;
416 }
417 let (mut current, mut detail) = activity::current(state, now);
418 if let Some(job) = &state.job
419 && let Some(approval) = awaiting.get(&(state.session.clone(), job.clone()))
420 && !state.ended
421 {
422 current = AgentState::AwaitingInput;
423 detail = Some(format!(
424 "waiting for your approval: {}",
425 bounded(&approval.summary, 200)
426 ));
427 }
428 runs.push(WorkbenchRun {
429 handle: state.handle.clone(),
430 agent: state.agent.clone(),
431 session: state.session.clone(),
432 transport: state.transport.clone(),
433 conversation: state
434 .conversation
435 .clone()
436 .filter(|handle| held(&state.owner, &state.session, handle)),
437 turn: state.turn,
438 job: state.job.clone(),
439 jobs: state.jobs.clone(),
440 cwd: state.cwd.display().to_string(),
441 state: current,
442 detail,
443 started_unix_seconds: state.started_unix,
444 last_activity_unix_seconds: state.last_activity_unix,
445 live: !state.ended && live_handles.contains(&state.handle),
446 steerable: state.steerable,
447 });
448 }
449
450 let all = ledger::list_all(&self.jobs);
451 let mut records: Vec<&JobRecord> = all.iter().filter(|record| !record.dismissed).collect();
452 records.sort_by_key(|record| std::cmp::Reverse((record.created_unix, record.job.clone())));
453 let mut jobs = Vec::new();
454 let mut finished_listed = 0;
455 let successors: HashMap<&str, String> = all
459 .iter()
460 .filter_map(|record| {
461 let from = record.resumed_from.as_deref()?;
462 Some((from, format!("{}/{}", record.session, record.job)))
463 })
464 .collect();
465 for &record in &records {
466 let key = (record.session.clone(), record.job.clone());
467 let resumed_as = record.resumed_as.clone().or_else(|| {
468 successors
469 .get(format!("{}/{}", record.session, record.job).as_str())
470 .cloned()
471 });
472 let live = live_jobs.get(&key);
473 let run = job_runs.get(&key).copied();
474 let (state, detail) = job_state(
475 record,
476 live,
477 run,
478 awaiting.get(&key).copied(),
479 live_sessions.contains(record.session.as_str()),
480 now,
481 );
482 let resumable_interrupted =
486 state == AgentState::Interrupted && resumed_as.is_none() && !record.reviewed;
487 if state.is_final() && !resumable_interrupted {
488 if finished_listed >= MAX_LISTED {
489 continue;
490 }
491 finished_listed += 1;
492 }
493 let cwd = run
494 .map(|run| run.cwd.display().to_string())
495 .or_else(|| {
496 record
497 .worktree
498 .as_ref()
499 .map(|worktree| worktree.path.clone())
500 })
501 .unwrap_or_else(|| {
502 let relative = record.arguments.get("cwd").and_then(Value::as_str);
503 relative.map_or_else(
504 || record.workspace.display().to_string(),
505 |cwd| record.workspace.join(cwd).display().to_string(),
506 )
507 });
508 jobs.push(WorkbenchJob {
509 session: record.session.clone(),
510 job: record.job.clone(),
511 agent: record.agent.clone(),
512 task: record.task.clone(),
513 workspace: record.workspace.display().to_string(),
514 cwd,
515 state,
516 detail,
517 started_by: record.started_by,
518 conversation: record
519 .conversation
520 .clone()
521 .or_else(|| run.and_then(|run| run.conversation.clone()))
522 .or_else(|| {
523 record
524 .arguments
525 .get("session")
526 .and_then(Value::as_str)
527 .map(str::to_owned)
528 })
529 .filter(|handle| held(&record.owner, &record.session, handle)),
530 run: run.map(|run| run.handle.clone()),
531 worktree: record.worktree.clone(),
532 reviewed: record.reviewed,
533 resumable: state == AgentState::Interrupted
534 && !record.reviewed
535 && resumed_as.is_none(),
536 resumed_as,
537 created_unix_seconds: record.created_unix,
538 updated_unix_seconds: record.updated_unix,
539 });
540 }
541
542 let mut sessions: Vec<WorkbenchSession> = slots
543 .iter()
544 .map(|slot| WorkbenchSession {
545 id: slot.id.clone(),
546 label: slot.label.clone(),
547 workspace: slot.workspace.display().to_string(),
548 live: true,
549 state: slot.state,
550 detail: slot.detail.clone(),
551 })
552 .collect();
553 let mut seen: HashSet<String> = sessions.iter().map(|session| session.id.clone()).collect();
554 for &record in &records {
555 if seen.insert(record.session.clone()) {
556 let alive = record.owner.is_alive() && record.owner.pid != std::process::id();
557 sessions.push(WorkbenchSession {
558 id: record.session.clone(),
559 label: record.label.clone(),
560 workspace: record.workspace.display().to_string(),
561 live: alive,
562 state: AgentState::Unknown,
563 detail: Some(if alive {
564 format!("runs in SCV process {}", record.owner.pid)
565 } else {
566 "ended".to_owned()
567 }),
568 });
569 }
570 }
571 sessions.sort_by(|a, b| b.live.cmp(&a.live).then_with(|| a.id.cmp(&b.id)));
572 WorkbenchSnapshot {
573 generated_unix_seconds: now,
574 sessions,
575 jobs,
576 runs,
577 approvals,
578 restorable: Vec::new(),
579 }
580 }
581
582 fn live_handles(&self) -> HashSet<String> {
585 self.delegations
586 .list(true)
587 .into_iter()
588 .filter(|entry| entry.processes > 0)
589 .map(|entry| entry.record.handle)
590 .collect()
591 }
592
593 pub fn check_worktree_unused(&self, path: &Path) -> Result<(), String> {
596 let users = self.worktree_users(path);
597 if users.is_empty() {
598 return Ok(());
599 }
600 Err(format!(
601 "{} still work in {}; stop them before removing it",
602 users.join(", "),
603 path.display()
604 ))
605 }
606
607 pub fn worktree_users(&self, path: &Path) -> Vec<String> {
611 let path = crate::delegate::worktree::worktree_path(&self.worktrees, path)
612 .or_else(|| std::fs::canonicalize(path).ok())
613 .unwrap_or_else(|| path.to_owned());
614 let mut users: Vec<String> = ledger::list(&self.jobs)
615 .into_iter()
616 .filter(|record| {
617 !record.state.is_final()
618 && record.owner.is_alive()
619 && record
620 .worktree
621 .as_ref()
622 .is_some_and(|worktree| Path::new(&worktree.path) == path)
623 })
624 .map(|record| format!("{}/{}", record.session, record.job))
625 .collect();
626 users.extend(
627 self.delegations
628 .list(true)
629 .into_iter()
630 .filter(|entry| entry.processes > 0 && entry.record.cwd.starts_with(&path))
631 .map(|entry| entry.record.handle),
632 );
633 users
634 }
635
636 pub fn run_log(&self, handle: &str, after: u64, limit: usize) -> Result<RunLogPage, String> {
638 activity::read_page(&self.runs, handle, after, limit).map_err(|error| {
639 if error.kind() == std::io::ErrorKind::NotFound {
640 format!("no activity log for run {handle:?}")
641 } else {
642 format!("cannot read run {handle:?}: {error}")
643 }
644 })
645 }
646
647 fn agent(&self, session: &str) -> Result<(Arc<BackgroundCapable>, PathBuf), String> {
649 let inner = lock(&self.inner);
650 let slot = inner.sessions.get(session).ok_or_else(|| {
651 format!("session {session} does not run in this daemon (it ended, or another SCV process runs it)")
652 })?;
653 let agent = slot
654 .agent
655 .as_ref()
656 .and_then(Weak::upgrade)
657 .ok_or_else(|| format!("session {session} runs no background agent jobs"))?;
658 Ok((agent, slot.workspace.clone()))
659 }
660
661 pub async fn send(
664 &self,
665 session: &str,
666 conversation: &str,
667 prompt: &str,
668 mode: SendMode,
669 ) -> Result<Sent, String> {
670 let (agent, workspace) = self.agent(session)?;
671 let call = agent
672 .owner_call(
673 json!({"session": conversation, "prompt": prompt}),
674 workspace,
675 mode,
676 JobMeta::default(),
677 )
678 .await
679 .map_err(|error| error.message)?;
680 Ok(match call {
681 OwnerCall::Job(started) => Sent::Job(job_name(session, &started)),
682 OwnerCall::Steered => Sent::Steered,
683 })
684 }
685
686 pub async fn start(&self, session: &str, arguments: Value) -> Result<String, String> {
689 self.start_with(session, arguments, JobMeta::default())
690 .await
691 }
692
693 async fn start_with(
695 &self,
696 session: &str,
697 arguments: Value,
698 meta: JobMeta,
699 ) -> Result<String, String> {
700 let (agent, workspace) = self.agent(session)?;
701 match agent
702 .owner_call(arguments, workspace, SendMode::Queue, meta)
703 .await
704 .map_err(|error| error.message)?
705 {
706 OwnerCall::Job(started) => Ok(job_name(session, &started)),
707 OwnerCall::Steered => Err("the prompt went into a running turn".into()),
708 }
709 }
710
711 pub async fn resume(&self, host: &str, session: &str, job: &str) -> Result<String, String> {
723 let key = format!("{session}/{job}");
724 if !lock(&self.resuming).insert(key.clone()) {
725 return Err(format!("{job} is being resumed already"));
726 }
727 let _claim = Claim {
729 claims: &self.resuming,
730 key: &key,
731 };
732 self.resume_once(host, session, job, &key).await
733 }
734
735 async fn resume_once(
736 &self,
737 host: &str,
738 session: &str,
739 job: &str,
740 key: &str,
741 ) -> Result<String, String> {
742 let record = ledger::load(&self.jobs, session, job)
743 .ok_or_else(|| format!("no record of {session}/{job}"))?;
744 let session_live = lock(&self.inner).sessions.contains_key(session);
745 let interrupted = record.state == AgentState::Interrupted
746 || (!record.state.is_final()
747 && (!record.owner.is_alive()
748 || (record.owner.pid == std::process::id() && !session_live)));
749 if !interrupted {
750 return Err(format!(
751 "{job} was not interrupted ({}); only an interrupted job can be resumed",
752 record.state
753 ));
754 }
755 if record.reviewed {
756 return Err(
757 "a reviewed job is not resumed: its journal is its record. Start a new \
758 reviewed call whose brief restates the open findings and anything already \
759 landed"
760 .into(),
761 );
762 }
763 if let Some(resumed) = &record.resumed_as {
764 return Err(format!("{job} was already resumed as {resumed}"));
765 }
766 let (agent, _) = self.agent(host)?;
767 let (successor, run, live, blocking) = {
770 let (jobs, runs) = (self.jobs.clone(), self.runs.clone());
771 let delegations = Arc::clone(&self.delegations);
772 let continued = record
773 .arguments
774 .get("session")
775 .and_then(Value::as_str)
776 .map(str::to_owned);
777 let (session, job, key) = (session.to_owned(), job.to_owned(), key.to_owned());
778 tokio::task::spawn_blocking(move || {
779 let successor = ledger::list_all(&jobs)
783 .into_iter()
784 .find(|record| record.resumed_from.as_deref() == Some(key.as_str()))
785 .map(|record| format!("{}/{}", record.session, record.job));
786 let states = activity::list_states(&runs);
787 let served = |state: &activity::RunState| {
788 state.session == session
789 && (state.job.as_deref() == Some(job.as_str()) || state.jobs.contains(&job))
790 };
791 let own = states
792 .iter()
793 .filter(|state| served(state))
794 .max_by_key(|state| state.started_unix)
795 .cloned();
796 let fallback = if own.is_none() {
800 continued.as_deref().and_then(|handle| {
801 states
802 .iter()
803 .filter(|state| {
804 state.session == session
805 && state.conversation.as_deref() == Some(handle)
806 })
807 .max_by_key(|state| state.started_unix)
808 .cloned()
809 })
810 } else {
811 None
812 };
813 let run = own.or_else(|| fallback.clone());
814 let blocking = match &fallback {
819 Some(found) if found.vendor.is_some() && found.cwd.is_dir() => {
820 unresumed_served(&jobs, found, &job)
821 }
822 _ => Vec::new(),
823 };
824 let live = run.as_ref().is_some_and(|run| {
825 delegations
826 .list(true)
827 .iter()
828 .any(|entry| entry.record.handle == run.handle && entry.processes > 0)
829 });
830 (successor, run, live, blocking)
831 })
832 .await
833 .map_err(|error| error.to_string())?
834 };
835 if let Some(resumed) = successor {
836 return Err(format!("{job} was already resumed as {resumed}"));
837 }
838 if live {
839 return Err(format!(
840 "{job}'s agent ({}) still runs; SCV stops what a stopped SCV process left \
841 within a minute (or `scv agents kill` it now). Resume it after that",
842 run.as_ref().map_or("", |run| run.handle.as_str())
843 ));
844 }
845 if !blocking.is_empty() {
846 let ahead = if blocking.len() == 1 { "it" } else { "them" };
847 return Err(format!(
848 "{job} was queued behind {}; resume {ahead} first. This follow-up then waits \
849 behind the same conversation, so one agent session is not continued twice",
850 blocking.join(", ")
851 ));
852 }
853 let mut record = record;
854 let cwd_live = run.as_ref().is_some_and(|found| found.cwd.is_dir());
855 if let Some(found) = &run
856 && cwd_live
857 {
858 remember_cwd(&mut record, &found.cwd);
859 if record.worktree.is_none()
860 && let Some(worktree) =
861 crate::delegate::worktree::worktree_ref(&self.worktrees, &found.cwd)
862 {
863 record.worktree = Some(worktree);
864 }
865 }
866 if record.worktree.is_none()
872 && let Some(found) = &run
873 && let Some(worktree) = served_worktree(&self.jobs, found)
874 {
875 record.worktree = Some(worktree);
876 }
877 if !cwd_live
878 && let Some(found) = &run
879 && let Some(worktree) = &record.worktree
880 && found.cwd.starts_with(Path::new(&worktree.path))
881 {
882 remember_cwd(&mut record, &found.cwd);
883 }
884 let cwd = run
885 .as_ref()
886 .map(|run| run.cwd.clone())
887 .filter(|cwd| cwd.is_dir());
888 let adopted = run
889 .as_ref()
890 .and_then(|run| run.vendor.as_deref().zip(cwd.as_deref()))
891 .and_then(|(vendor, cwd)| agent.adopt(&record.agent, cwd, vendor));
892 let mut arguments = if let Some(handle) = &adopted {
893 json!({
894 "session": handle,
895 "prompt": format!(
896 "[SCV] SCV stopped before your last turn on this task finished (a restart \
897 or a crash), so it was cut off. Check where the work stands (files, git \
898 status, anything still running), then carry on. The task was:\n\n{}",
899 record.prompt
900 ),
901 })
902 } else {
903 rerun_arguments(&record, |option| agent.takes(&record.agent, option))
904 };
905 if let Some(object) = arguments.as_object_mut() {
906 for key in ["timeout_seconds", "no_timeout"] {
907 if let Some(value) = record.arguments.get(key) {
908 object.insert(key.into(), value.clone());
909 }
910 }
911 }
912 let meta = JobMeta {
914 resumed_from: Some(key.to_owned()),
915 worktree: record
916 .worktree
917 .clone()
918 .filter(|worktree| Path::new(&worktree.path).is_dir()),
919 ..JobMeta::default()
920 };
921 let resumed = self.start_with(host, arguments, meta).await?;
922 ledger::update(&self.jobs, session, job, |old| {
923 old.resumed_as = Some(resumed.clone());
924 if !old.state.is_final() {
925 ledger::interrupt(old, "its session ended before it finished");
926 }
927 });
928 Ok(resumed)
929 }
930
931 pub fn cancel(&self, session: &str, job: &str) -> Result<String, String> {
933 let jobs = {
934 let inner = lock(&self.inner);
935 inner
936 .sessions
937 .get(session)
938 .and_then(|slot| slot.jobs.as_ref())
939 .and_then(Weak::upgrade)
940 };
941 let Some(jobs) = jobs else {
942 return Err(format!(
943 "session {session} does not run in this daemon; a job whose session ended \
944 stopped with it, and `scv agents kill` stops another process's runs"
945 ));
946 };
947 jobs.cancel_for_owner(job)?;
948 Ok(format!(
949 "Stopping {job}: its agent and every process it started are stopped; work it \
950 already wrote stays."
951 ))
952 }
953
954 pub fn dismiss(&self, session: &str, job: &str) -> Result<String, String> {
956 let record = ledger::load(&self.jobs, session, job)
957 .ok_or_else(|| format!("no record of {session}/{job}"))?;
958 let jobs = lock(&self.inner)
959 .sessions
960 .get(session)
961 .and_then(|slot| slot.jobs.as_ref())
962 .and_then(Weak::upgrade);
963 let running = jobs.as_ref().is_some_and(|jobs| {
964 jobs.live()
965 .iter()
966 .any(|live| live.id == job && live.running)
967 });
968 if running || (!record.state.is_final() && record.owner.is_alive()) {
969 return Err(format!("{job} is still running; stop it first"));
970 }
971 let elsewhere =
975 record.owner.is_alive() && ProcessIdentity::current().as_ref() != Some(&record.owner);
976 if jobs.is_some_and(|jobs| jobs.unreported(job)) || (elsewhere && !record.reported) {
977 return Err(format!(
978 "{job}'s result has not reached its session yet; dismiss it once it has"
979 ));
980 }
981 ledger::dismiss(&self.jobs, session, job).map_err(|error| error.to_string())?;
982 Ok(format!("Forgot {session}/{job}."))
983 }
984
985 pub fn job_record(&self, session: &str, job: &str) -> Option<JobRecord> {
987 ledger::load(&self.jobs, session, job)
988 }
989
990 pub fn maintain(&self) -> Maintenance {
993 let interrupted_jobs = ledger::settle_orphans(&self.jobs)
994 .into_iter()
995 .map(|record| format!("{}/{}", record.session, record.job))
996 .collect();
997 let interrupted_runs = activity::settle_orphans(&self.runs);
998 let pruned = ledger::prune(&self.jobs, KEEP) + activity::prune(&self.runs, KEEP, MAX_RUNS);
999 Maintenance {
1000 interrupted_jobs,
1001 interrupted_runs,
1002 pruned,
1003 }
1004 }
1005}
1006
1007struct Claim<'a> {
1009 claims: &'a Mutex<HashSet<String>>,
1010 key: &'a str,
1011}
1012
1013impl Drop for Claim<'_> {
1014 fn drop(&mut self) {
1015 lock(self.claims).remove(self.key);
1016 }
1017}
1018
1019struct SlotView {
1020 id: String,
1021 label: String,
1022 workspace: PathBuf,
1023 state: AgentState,
1024 detail: Option<String>,
1025 agent: Option<Arc<BackgroundCapable>>,
1026 jobs: Option<Arc<BackgroundJobs>>,
1027}
1028
1029fn job_state(
1032 record: &JobRecord,
1033 live: Option<&LiveJob>,
1034 run: Option<&RunState>,
1035 awaiting: Option<&WorkbenchApproval>,
1036 session_live: bool,
1037 now: u64,
1038) -> (AgentState, Option<String>) {
1039 if record.state.is_final() {
1040 let detail = match record.state {
1041 AgentState::Interrupted => record.interrupted.clone(),
1042 AgentState::Completed => None,
1043 _ => record
1044 .error
1045 .as_deref()
1046 .and_then(|error| error.lines().find(|line| !line.trim().is_empty()))
1047 .map(|line| bounded(line, 200)),
1048 };
1049 return (record.state, detail);
1050 }
1051 if let Some(approval) = awaiting {
1052 return (
1053 AgentState::AwaitingInput,
1054 Some(format!(
1055 "waiting for your approval: {}",
1056 bounded(&approval.summary, 200)
1057 )),
1058 );
1059 }
1060 if let Some(live) = live {
1061 if live.waiting {
1062 let behind = record
1063 .arguments
1064 .get("session")
1065 .and_then(Value::as_str)
1066 .map_or_else(String::new, |handle| format!(" {handle}'s"));
1067 return (
1068 AgentState::Queued,
1069 Some(format!("waits for conversation{behind} running turn")),
1070 );
1071 }
1072 if live.running {
1073 if let Some(run) = run.filter(|run| !run.ended) {
1074 return activity::current(run, now);
1075 }
1076 return (
1077 AgentState::Working,
1078 live.progress
1079 .clone()
1080 .or_else(|| Some(format!("running for {} s", live.elapsed.as_secs()))),
1081 );
1082 }
1083 }
1084 if !record.owner.is_alive() {
1085 return (
1086 AgentState::Interrupted,
1087 Some("the SCV process that ran it stopped".into()),
1088 );
1089 }
1090 if record.owner.pid == std::process::id() && !session_live {
1091 return (
1092 AgentState::Interrupted,
1093 Some("its session ended before it finished".into()),
1094 );
1095 }
1096 match run {
1097 Some(run) if !run.ended => activity::current(run, now),
1098 _ => (record.state, None),
1099 }
1100}
1101
1102pub fn host_session(workspace: &Path) -> String {
1105 use sha2::{Digest, Sha256};
1106 let digest = Sha256::digest(workspace.as_os_str().as_encoded_bytes());
1107 let hex: String = digest[..8]
1108 .iter()
1109 .map(|byte| format!("{byte:02x}"))
1110 .collect();
1111 format!("workbench-{hex}")
1112}
1113
1114fn job_name(session: &str, started: &Value) -> String {
1116 format!(
1117 "{session}/{}",
1118 started.get("job").and_then(Value::as_str).unwrap_or("job")
1119 )
1120}
1121
1122struct AttendedGate {
1125 workbench: Weak<Workbench>,
1126 session: String,
1127 unattended: Arc<dyn ApprovalGate>,
1128}
1129
1130#[async_trait]
1131impl ApprovalGate for AttendedGate {
1132 async fn approve(
1133 &self,
1134 request: ApprovalRequest,
1135 cancellation: CancellationToken,
1136 ) -> Result<bool, AgentError> {
1137 if self
1138 .unattended
1139 .approve(request.clone(), cancellation.clone())
1140 .await?
1141 {
1142 return Ok(true);
1143 }
1144 match self.workbench.upgrade() {
1145 Some(workbench) if workbench.watched() => {
1146 workbench.ask(&self.session, request, &cancellation).await
1147 }
1148 _ => Ok(false),
1149 }
1150 }
1151}
1152
1153fn bounded(text: &str, max: usize) -> String {
1154 if text.chars().count() <= max {
1155 return text.to_owned();
1156 }
1157 let mut cut: String = text.chars().take(max.saturating_sub(1)).collect();
1158 cut.push('…');
1159 cut
1160}
1161
1162fn unix_now() -> u64 {
1163 SystemTime::now()
1164 .duration_since(UNIX_EPOCH)
1165 .map_or(0, |elapsed| elapsed.as_secs())
1166}
1167
1168fn served_job_names(run: &RunState) -> Vec<&str> {
1170 let mut names = Vec::new();
1171 if let Some(job) = run.job.as_deref() {
1172 names.push(job);
1173 }
1174 for job in &run.jobs {
1175 if !names.contains(&job.as_str()) {
1176 names.push(job.as_str());
1177 }
1178 }
1179 names
1180}
1181
1182fn unresumed_served(root: &Path, run: &RunState, skip: &str) -> Vec<String> {
1185 served_job_names(run)
1186 .into_iter()
1187 .filter(|name| *name != skip)
1188 .filter_map(|name| {
1189 let record = ledger::load(root, &run.session, name)?;
1190 record
1191 .resumable()
1192 .then(|| format!("{}/{}", run.session, name))
1193 })
1194 .collect()
1195}
1196
1197fn served_worktree(root: &Path, run: &RunState) -> Option<scv_protocol::WorktreeRef> {
1201 served_job_names(run).into_iter().find_map(|name| {
1202 ledger::load_including_dismissed(root, &run.session, name)
1203 .and_then(|record| record.worktree)
1204 })
1205}
1206
1207fn remember_cwd(record: &mut ledger::JobRecord, cwd: &Path) {
1209 let has_cwd = record
1210 .arguments
1211 .get("cwd")
1212 .and_then(Value::as_str)
1213 .is_some_and(|cwd| !cwd.is_empty());
1214 if !has_cwd && let Some(object) = record.arguments.as_object_mut() {
1215 object.insert("cwd".into(), cwd.display().to_string().into());
1216 }
1217}
1218
1219fn rerun_arguments(record: &JobRecord, takes: impl Fn(&str) -> bool) -> Value {
1226 let mut arguments = record.arguments.clone();
1227 if let Some(object) = arguments.as_object_mut() {
1228 object.remove("session");
1229 object.remove("worktree");
1230 object.remove("on_busy");
1231 for option in ["model", "effort"] {
1234 if !takes(option) {
1235 object.remove(option);
1236 }
1237 }
1238 object.insert("agent".into(), record.agent.clone().into());
1239 object.insert(
1240 "prompt".into(),
1241 format!(
1242 "[SCV] An earlier attempt at this task was cut off when SCV stopped (a restart \
1243 or a crash), and its conversation could not be resumed. It may have left \
1244 partial changes here: check them first, then do the task:\n\n{}",
1245 record.prompt
1246 )
1247 .into(),
1248 );
1249 if let Some(worktree) = &record.worktree {
1250 let top = Path::new(&worktree.path);
1251 let inside = object
1253 .get("cwd")
1254 .and_then(Value::as_str)
1255 .and_then(|cwd| Path::new(cwd).strip_prefix(top).ok())
1256 .map(Path::to_path_buf)
1257 .unwrap_or_default();
1258 if top.is_dir() {
1259 let cwd = Some(top.join(&inside))
1260 .filter(|cwd| cwd.is_dir())
1261 .unwrap_or_else(|| top.to_owned());
1262 object.insert("cwd".into(), cwd.display().to_string().into());
1263 } else {
1264 let repo = Path::new(&worktree.repo);
1265 let cwd = Some(repo.join(&inside))
1266 .filter(|cwd| cwd.is_dir())
1267 .unwrap_or_else(|| repo.to_owned());
1268 object.insert("cwd".into(), cwd.display().to_string().into());
1269 object.insert("worktree".into(), true.into());
1270 }
1271 }
1272 }
1273 arguments
1274}
1275
1276#[cfg(test)]
1277mod tests;