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 MAX_RUNS: usize = 500;
76pub const ATTACH_LEASE: Duration = Duration::from_secs(30);
80const CLOSED_KEPT: Duration = Duration::from_secs(60);
83const MAX_TAKEOVERS: usize = 8;
85
86pub struct Workbench {
88 runs: PathBuf,
89 jobs: PathBuf,
90 worktrees: PathBuf,
91 delegations: Arc<DelegationRegistry>,
92 inner: Mutex<Inner>,
93 watched_until: Mutex<Option<Instant>>,
94 resuming: Mutex<HashSet<String>>,
96 takeovers: Mutex<HashMap<String, Held>>,
99 attaching: tokio::sync::Mutex<()>,
102 closing: AtomicBool,
105}
106
107struct Held {
109 takeover: Arc<Takeover>,
110 lease: Option<(String, Instant)>,
112 closed: Option<Instant>,
114}
115
116impl std::fmt::Debug for Workbench {
117 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 formatter.debug_struct("Workbench").finish_non_exhaustive()
119 }
120}
121
122#[derive(Default)]
123struct Inner {
124 sessions: HashMap<String, Slot>,
125 approvals: BTreeMap<String, Pending>,
126 next: u64,
127}
128
129struct Slot {
131 token: u64,
132 label: String,
133 workspace: PathBuf,
134 state: AgentState,
135 detail: Option<String>,
136 agent: Option<Weak<BackgroundCapable>>,
137 jobs: Option<Weak<BackgroundJobs>>,
138}
139
140struct Pending {
141 info: WorkbenchApproval,
142 answer: Option<oneshot::Sender<bool>>,
143}
144
145pub struct SessionHandle {
148 workbench: Weak<Workbench>,
149 id: String,
150 token: u64,
151}
152
153impl std::fmt::Debug for SessionHandle {
154 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155 formatter
156 .debug_struct("SessionHandle")
157 .field("id", &self.id)
158 .finish()
159 }
160}
161
162impl SessionHandle {
163 pub fn set_state(&self, state: AgentState, detail: Option<String>) {
166 let Some(workbench) = self.workbench.upgrade() else {
167 return;
168 };
169 if let Some(slot) = lock(&workbench.inner).sessions.get_mut(&self.id)
170 && slot.token == self.token
171 {
172 slot.state = state;
173 slot.detail = detail;
174 }
175 }
176}
177
178impl Drop for SessionHandle {
179 fn drop(&mut self) {
180 let Some(workbench) = self.workbench.upgrade() else {
181 return;
182 };
183 let mut inner = lock(&workbench.inner);
184 if inner
185 .sessions
186 .get(&self.id)
187 .is_some_and(|slot| slot.token == self.token)
188 {
189 inner.sessions.remove(&self.id);
190 drop(inner);
191 workbench.end_takeovers_of(Some(&self.id));
193 }
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
199pub enum Sent {
200 Job(String),
202 Steered,
204}
205
206#[derive(Debug, Clone, Default, PartialEq, Eq)]
208pub struct Maintenance {
209 pub interrupted_jobs: Vec<String>,
211 pub interrupted_runs: Vec<String>,
213 pub pruned: usize,
215}
216
217impl Workbench {
218 pub fn new(layout: &Layout, delegations: Arc<DelegationRegistry>) -> Arc<Self> {
221 Arc::new(Self {
222 runs: layout.runs(),
223 jobs: layout.jobs(),
224 worktrees: layout.worktrees(),
225 delegations,
226 inner: Mutex::default(),
227 watched_until: Mutex::new(None),
228 resuming: Mutex::default(),
229 takeovers: Mutex::default(),
230 attaching: tokio::sync::Mutex::const_new(()),
231 closing: AtomicBool::new(false),
232 })
233 }
234
235 pub fn jobs_dir(&self) -> &Path {
237 &self.jobs
238 }
239
240 pub fn register_session(
242 self: &Arc<Self>,
243 id: &str,
244 label: &str,
245 workspace: &Path,
246 ) -> SessionHandle {
247 let mut inner = lock(&self.inner);
248 inner.next += 1;
249 let token = inner.next;
250 inner.sessions.insert(
251 id.to_owned(),
252 Slot {
253 token,
254 label: label.to_owned(),
255 workspace: workspace.to_owned(),
256 state: AgentState::Idle,
257 detail: None,
258 agent: None,
259 jobs: None,
260 },
261 );
262 SessionHandle {
263 workbench: Arc::downgrade(self),
264 id: id.to_owned(),
265 token,
266 }
267 }
268
269 pub(crate) fn attach(
271 &self,
272 session: &str,
273 agent: Weak<BackgroundCapable>,
274 jobs: Weak<BackgroundJobs>,
275 ) {
276 if let Some(slot) = lock(&self.inner).sessions.get_mut(session) {
277 slot.agent = Some(agent);
278 slot.jobs = Some(jobs);
279 }
280 }
281
282 pub fn watch(&self) {
285 *lock(&self.watched_until) = Some(Instant::now() + WATCH_TTL);
286 }
287
288 fn watched(&self) -> bool {
289 lock(&self.watched_until).is_some_and(|until| Instant::now() < until)
290 }
291
292 pub fn attended_gate(
297 self: &Arc<Self>,
298 session: &str,
299 unattended: Arc<dyn ApprovalGate>,
300 ) -> Arc<dyn ApprovalGate> {
301 Arc::new(AttendedGate {
302 workbench: Arc::downgrade(self),
303 session: session.to_owned(),
304 unattended,
305 })
306 }
307
308 async fn ask(
310 &self,
311 session: &str,
312 request: ApprovalRequest,
313 cancellation: &CancellationToken,
314 ) -> Result<bool, AgentError> {
315 let (sender, receiver) = oneshot::channel();
316 let now = unix_now();
317 let id = format!("ap-{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
318 lock(&self.inner).approvals.insert(
319 id.clone(),
320 Pending {
321 info: WorkbenchApproval {
322 id: id.clone(),
323 session: session.to_owned(),
324 job: request.call_id.clone(),
325 name: request.name.clone(),
326 risk: request.risk.as_str().to_owned(),
327 cwd: request.cwd.display().to_string(),
328 summary: request.summary.clone(),
329 since_unix_seconds: now,
330 expires_unix_seconds: now + ATTENDED_WAIT.as_secs(),
331 },
332 answer: Some(sender),
333 },
334 );
335 let mut receiver = receiver;
339 let answer = tokio::select! {
340 biased;
341 answer = &mut receiver => Ok(answer.unwrap_or(false)),
342 () = tokio::time::sleep(ATTENDED_WAIT) => {
343 receiver.close();
344 Ok(receiver.try_recv().unwrap_or(false))
345 }
346 () = cancellation.cancelled() => {
347 receiver.close();
348 match receiver.try_recv() {
349 Ok(approved) => Ok(approved),
350 Err(_) => Err(AgentError::Cancelled),
351 }
352 }
353 };
354 lock(&self.inner).approvals.remove(&id);
355 answer
356 }
357
358 pub fn approve(&self, id: &str, approved: bool) -> Result<String, String> {
360 let pending = lock(&self.inner).approvals.get_mut(id).and_then(|pending| {
361 pending
362 .answer
363 .take()
364 .map(|answer| (answer, pending.info.clone()))
365 });
366 let Some((answer, info)) = pending else {
367 return Err(format!(
368 "no approval request {id:?} waits; it may have been answered or timed out"
369 ));
370 };
371 if answer.send(approved).is_err() {
372 return Err(format!("{} no longer waits for this answer", info.job));
373 }
374 Ok(format!(
375 "{} {}: {}",
376 if approved { "Approved" } else { "Denied" },
377 info.job,
378 bounded(&info.summary, 120)
379 ))
380 }
381
382 pub fn snapshot(&self) -> WorkbenchSnapshot {
385 self.expire_takeovers();
386 let now = unix_now();
387 let live_handles = self.live_handles();
388 let (slots, approvals): (Vec<_>, Vec<_>) = {
389 let inner = lock(&self.inner);
390 (
391 inner
392 .sessions
393 .iter()
394 .map(|(id, slot)| SlotView {
395 id: id.clone(),
396 label: slot.label.clone(),
397 workspace: slot.workspace.clone(),
398 state: slot.state,
399 detail: slot.detail.clone(),
400 agent: slot.agent.as_ref().and_then(Weak::upgrade),
401 jobs: slot.jobs.as_ref().and_then(Weak::upgrade),
402 })
403 .collect(),
404 inner
405 .approvals
406 .values()
407 .map(|pending| pending.info.clone())
408 .collect(),
409 )
410 };
411 let live_jobs: HashMap<(String, String), LiveJob> = slots
412 .iter()
413 .filter_map(|slot| {
414 slot.jobs
415 .as_ref()
416 .map(|jobs| (slot.id.clone(), jobs.live()))
417 })
418 .flat_map(|(session, jobs)| {
419 jobs.into_iter()
420 .map(move |job| ((session.clone(), job.id.clone()), job))
421 })
422 .collect();
423 let live_sessions: HashSet<&str> = slots.iter().map(|slot| slot.id.as_str()).collect();
424 let awaiting: HashMap<(String, String), &WorkbenchApproval> = approvals
425 .iter()
426 .map(|approval| ((approval.session.clone(), approval.job.clone()), approval))
427 .collect();
428
429 let me = ProcessIdentity::current();
436 let held = |owner: &ProcessIdentity, session: &str, handle: &str| {
437 me.as_ref() == Some(owner)
438 && slots.iter().any(|slot| {
439 slot.id == session
440 && slot.agent.as_ref().is_some_and(|agent| agent.knows(handle))
441 })
442 };
443 let taken_over = self.running_takeovers();
445 let mut offers: HashMap<(String, String), Option<AttachOffer>> = HashMap::new();
446 let mut offer = |session: &str, handle: &str| {
447 offers
448 .entry((session.to_owned(), handle.to_owned()))
449 .or_insert_with(|| {
450 let slot = slots.iter().find(|slot| slot.id == session)?;
451 let offer = slot.agent.as_ref()?.attach_offer(handle)?;
452 let approval = offer.job.as_ref().and_then(|job| {
453 awaiting
454 .get(&(session.to_owned(), job.clone()))
455 .map(|approval| approval.id.as_str())
456 });
457 Some(settle_offer(offer, approval, taken_over))
458 })
459 .clone()
460 };
461 let mut states = activity::list_states(&self.runs);
462 states
463 .sort_by_key(|state| std::cmp::Reverse((state.last_activity_unix, state.started_unix)));
464 let mut runs: Vec<WorkbenchRun> = Vec::new();
465 let mut job_runs: HashMap<(String, String), &RunState> = HashMap::new();
466 for state in &states {
467 for job in state.jobs.iter().chain(&state.job) {
468 job_runs
469 .entry((state.session.clone(), job.clone()))
470 .or_insert(state);
471 }
472 }
473 let mut ended_listed = 0;
474 for state in &states {
475 if state.ended {
476 if ended_listed >= MAX_LISTED {
477 continue;
478 }
479 ended_listed += 1;
480 }
481 let (mut current, mut detail) = activity::current(state, now);
482 if let Some(job) = &state.job
483 && let Some(approval) = awaiting.get(&(state.session.clone(), job.clone()))
484 && !state.ended
485 {
486 current = AgentState::AwaitingInput;
487 detail = Some(format!(
488 "waiting for your approval: {}",
489 bounded(&approval.summary, 200)
490 ));
491 }
492 let conversation = state
493 .conversation
494 .clone()
495 .filter(|handle| held(&state.owner, &state.session, handle));
496 runs.push(WorkbenchRun {
497 handle: state.handle.clone(),
498 agent: state.agent.clone(),
499 session: state.session.clone(),
500 transport: state.transport.clone(),
501 attach: conversation
502 .as_deref()
503 .and_then(|handle| offer(&state.session, handle)),
504 conversation,
505 turn: state.turn,
506 job: state.job.clone(),
507 jobs: state.jobs.clone(),
508 cwd: state.cwd.display().to_string(),
509 state: current,
510 detail,
511 started_unix_seconds: state.started_unix,
512 last_activity_unix_seconds: state.last_activity_unix,
513 live: !state.ended && live_handles.contains(&state.handle),
514 steerable: state.steerable,
515 });
516 }
517
518 let all = ledger::list_all(&self.jobs);
519 let mut records: Vec<&JobRecord> = all.iter().filter(|record| !record.dismissed).collect();
520 records.sort_by_key(|record| std::cmp::Reverse((record.created_unix, record.job.clone())));
521 let mut jobs = Vec::new();
522 let mut finished_listed = 0;
523 let successors: HashMap<&str, String> = all
527 .iter()
528 .filter_map(|record| {
529 let from = record.resumed_from.as_deref()?;
530 Some((from, format!("{}/{}", record.session, record.job)))
531 })
532 .collect();
533 for &record in &records {
534 let key = (record.session.clone(), record.job.clone());
535 let resumed_as = record.resumed_as.clone().or_else(|| {
536 successors
537 .get(format!("{}/{}", record.session, record.job).as_str())
538 .cloned()
539 });
540 let live = live_jobs.get(&key);
541 let run = job_runs.get(&key).copied();
542 let (state, detail) = job_state(
543 record,
544 live,
545 run,
546 awaiting.get(&key).copied(),
547 live_sessions.contains(record.session.as_str()),
548 now,
549 );
550 let resumable_interrupted =
554 state == AgentState::Interrupted && resumed_as.is_none() && !record.reviewed;
555 if state.is_final() && !resumable_interrupted {
556 if finished_listed >= MAX_LISTED {
557 continue;
558 }
559 finished_listed += 1;
560 }
561 let cwd = run
562 .map(|run| run.cwd.display().to_string())
563 .or_else(|| {
564 record
565 .worktree
566 .as_ref()
567 .map(|worktree| worktree.path.clone())
568 })
569 .unwrap_or_else(|| {
570 let relative = record.arguments.get("cwd").and_then(Value::as_str);
571 relative.map_or_else(
572 || record.workspace.display().to_string(),
573 |cwd| record.workspace.join(cwd).display().to_string(),
574 )
575 });
576 let conversation = record
577 .conversation
578 .clone()
579 .or_else(|| run.and_then(|run| run.conversation.clone()))
580 .or_else(|| {
581 record
582 .arguments
583 .get("session")
584 .and_then(Value::as_str)
585 .map(str::to_owned)
586 })
587 .filter(|handle| held(&record.owner, &record.session, handle));
588 jobs.push(WorkbenchJob {
589 session: record.session.clone(),
590 job: record.job.clone(),
591 agent: record.agent.clone(),
592 task: record.task.clone(),
593 workspace: record.workspace.display().to_string(),
594 cwd,
595 state,
596 detail,
597 started_by: record.started_by,
598 attach: conversation
599 .as_deref()
600 .and_then(|handle| offer(&record.session, handle)),
601 conversation,
602 run: run.map(|run| run.handle.clone()),
603 worktree: record.worktree.clone(),
604 reviewed: record.reviewed,
605 resumable: state == AgentState::Interrupted
606 && !record.reviewed
607 && resumed_as.is_none(),
608 resumed_as,
609 created_unix_seconds: record.created_unix,
610 updated_unix_seconds: record.updated_unix,
611 });
612 }
613
614 let mut sessions: Vec<WorkbenchSession> = slots
615 .iter()
616 .map(|slot| WorkbenchSession {
617 id: slot.id.clone(),
618 label: slot.label.clone(),
619 workspace: slot.workspace.display().to_string(),
620 live: true,
621 state: slot.state,
622 detail: slot.detail.clone(),
623 })
624 .collect();
625 let mut seen: HashSet<String> = sessions.iter().map(|session| session.id.clone()).collect();
626 for &record in &records {
627 if seen.insert(record.session.clone()) {
628 let alive = record.owner.is_alive() && record.owner.pid != std::process::id();
629 sessions.push(WorkbenchSession {
630 id: record.session.clone(),
631 label: record.label.clone(),
632 workspace: record.workspace.display().to_string(),
633 live: alive,
634 state: AgentState::Unknown,
635 detail: Some(if alive {
636 format!("runs in SCV process {}", record.owner.pid)
637 } else {
638 "ended".to_owned()
639 }),
640 });
641 }
642 }
643 sessions.sort_by(|a, b| b.live.cmp(&a.live).then_with(|| a.id.cmp(&b.id)));
644 WorkbenchSnapshot {
645 generated_unix_seconds: now,
646 sessions,
647 jobs,
648 runs,
649 approvals,
650 restorable: Vec::new(),
651 }
652 }
653
654 fn live_handles(&self) -> HashSet<String> {
657 self.delegations
658 .list(true)
659 .into_iter()
660 .filter(|entry| entry.processes > 0)
661 .map(|entry| entry.record.handle)
662 .collect()
663 }
664
665 pub fn check_worktree_unused(&self, path: &Path) -> Result<(), String> {
668 let users = self.worktree_users(path);
669 if users.is_empty() {
670 return Ok(());
671 }
672 Err(format!(
673 "{} still work in {}; stop them before removing it",
674 users.join(", "),
675 path.display()
676 ))
677 }
678
679 pub fn worktree_users(&self, path: &Path) -> Vec<String> {
683 let path = crate::delegate::worktree::worktree_path(&self.worktrees, path)
684 .or_else(|| std::fs::canonicalize(path).ok())
685 .unwrap_or_else(|| path.to_owned());
686 let mut users: Vec<String> = ledger::list(&self.jobs)
687 .into_iter()
688 .filter(|record| {
689 !record.state.is_final()
690 && record.owner.is_alive()
691 && record
692 .worktree
693 .as_ref()
694 .is_some_and(|worktree| Path::new(&worktree.path) == path)
695 })
696 .map(|record| format!("{}/{}", record.session, record.job))
697 .collect();
698 users.extend(
699 self.delegations
700 .list(true)
701 .into_iter()
702 .filter(|entry| entry.processes > 0 && entry.record.cwd.starts_with(&path))
703 .map(|entry| entry.record.handle),
704 );
705 users
706 }
707
708 pub fn run_log(&self, handle: &str, after: u64, limit: usize) -> Result<RunLogPage, String> {
710 activity::read_page(&self.runs, handle, after, limit).map_err(|error| {
711 if error.kind() == std::io::ErrorKind::NotFound {
712 format!("no activity log for run {handle:?}")
713 } else {
714 format!("cannot read run {handle:?}: {error}")
715 }
716 })
717 }
718
719 fn agent(&self, session: &str) -> Result<(Arc<BackgroundCapable>, PathBuf), String> {
721 let inner = lock(&self.inner);
722 let slot = inner.sessions.get(session).ok_or_else(|| {
723 format!("session {session} does not run in this daemon (it ended, or another SCV process runs it)")
724 })?;
725 let agent = slot
726 .agent
727 .as_ref()
728 .and_then(Weak::upgrade)
729 .ok_or_else(|| format!("session {session} runs no background agent jobs"))?;
730 Ok((agent, slot.workspace.clone()))
731 }
732
733 pub async fn send(
736 &self,
737 session: &str,
738 conversation: &str,
739 prompt: &str,
740 mode: SendMode,
741 ) -> Result<Sent, String> {
742 let (agent, workspace) = self.agent(session)?;
743 let call = agent
744 .owner_call(
745 json!({"session": conversation, "prompt": prompt}),
746 workspace,
747 mode,
748 JobMeta::default(),
749 )
750 .await
751 .map_err(|error| error.message)?;
752 Ok(match call {
753 OwnerCall::Job(started) => Sent::Job(job_name(session, &started)),
754 OwnerCall::Steered => Sent::Steered,
755 })
756 }
757
758 pub async fn start(&self, session: &str, arguments: Value) -> Result<String, String> {
761 self.start_with(session, arguments, JobMeta::default())
762 .await
763 }
764
765 async fn start_with(
767 &self,
768 session: &str,
769 arguments: Value,
770 meta: JobMeta,
771 ) -> Result<String, String> {
772 let (agent, workspace) = self.agent(session)?;
773 match agent
774 .owner_call(arguments, workspace, SendMode::Queue, meta)
775 .await
776 .map_err(|error| error.message)?
777 {
778 OwnerCall::Job(started) => Ok(job_name(session, &started)),
779 OwnerCall::Steered => Err("the prompt went into a running turn".into()),
780 }
781 }
782
783 pub async fn resume(&self, host: &str, session: &str, job: &str) -> Result<String, String> {
795 let key = format!("{session}/{job}");
796 if !lock(&self.resuming).insert(key.clone()) {
797 return Err(format!("{job} is being resumed already"));
798 }
799 let _claim = Claim {
801 claims: &self.resuming,
802 key: &key,
803 };
804 self.resume_once(host, session, job, &key).await
805 }
806
807 async fn resume_once(
808 &self,
809 host: &str,
810 session: &str,
811 job: &str,
812 key: &str,
813 ) -> Result<String, String> {
814 let record = ledger::load(&self.jobs, session, job)
815 .ok_or_else(|| format!("no record of {session}/{job}"))?;
816 let session_live = lock(&self.inner).sessions.contains_key(session);
817 let interrupted = record.state == AgentState::Interrupted
818 || (!record.state.is_final()
819 && (!record.owner.is_alive()
820 || (record.owner.pid == std::process::id() && !session_live)));
821 if !interrupted {
822 return Err(format!(
823 "{job} was not interrupted ({}); only an interrupted job can be resumed",
824 record.state
825 ));
826 }
827 if record.reviewed {
828 return Err(
829 "a reviewed job is not resumed: its journal is its record. Start a new \
830 reviewed call whose brief restates the open findings and anything already \
831 landed"
832 .into(),
833 );
834 }
835 if let Some(resumed) = &record.resumed_as {
836 return Err(format!("{job} was already resumed as {resumed}"));
837 }
838 let (agent, _) = self.agent(host)?;
839 let (successor, run, live, blocking) = {
842 let (jobs, runs) = (self.jobs.clone(), self.runs.clone());
843 let delegations = Arc::clone(&self.delegations);
844 let continued = record
845 .arguments
846 .get("session")
847 .and_then(Value::as_str)
848 .map(str::to_owned);
849 let (session, job, key) = (session.to_owned(), job.to_owned(), key.to_owned());
850 tokio::task::spawn_blocking(move || {
851 let successor = ledger::list_all(&jobs)
855 .into_iter()
856 .find(|record| record.resumed_from.as_deref() == Some(key.as_str()))
857 .map(|record| format!("{}/{}", record.session, record.job));
858 let states = activity::list_states(&runs);
859 let served = |state: &activity::RunState| {
860 state.session == session
861 && (state.job.as_deref() == Some(job.as_str()) || state.jobs.contains(&job))
862 };
863 let own = states
864 .iter()
865 .filter(|state| served(state))
866 .max_by_key(|state| state.started_unix)
867 .cloned();
868 let fallback = if own.is_none() {
872 continued.as_deref().and_then(|handle| {
873 states
874 .iter()
875 .filter(|state| {
876 state.session == session
877 && state.conversation.as_deref() == Some(handle)
878 })
879 .max_by_key(|state| state.started_unix)
880 .cloned()
881 })
882 } else {
883 None
884 };
885 let run = own.or_else(|| fallback.clone());
886 let blocking = match &fallback {
891 Some(found) if found.vendor.is_some() && found.cwd.is_dir() => {
892 unresumed_served(&jobs, found, &job)
893 }
894 _ => Vec::new(),
895 };
896 let live = run.as_ref().is_some_and(|run| {
897 delegations
898 .list(true)
899 .iter()
900 .any(|entry| entry.record.handle == run.handle && entry.processes > 0)
901 });
902 (successor, run, live, blocking)
903 })
904 .await
905 .map_err(|error| error.to_string())?
906 };
907 if let Some(resumed) = successor {
908 return Err(format!("{job} was already resumed as {resumed}"));
909 }
910 if live {
911 return Err(format!(
912 "{job}'s agent ({}) still runs; SCV stops what a stopped SCV process left \
913 within a minute (or `scv agents kill` it now). Resume it after that",
914 run.as_ref().map_or("", |run| run.handle.as_str())
915 ));
916 }
917 if !blocking.is_empty() {
918 let ahead = if blocking.len() == 1 { "it" } else { "them" };
919 return Err(format!(
920 "{job} was queued behind {}; resume {ahead} first. This follow-up then waits \
921 behind the same conversation, so one agent session is not continued twice",
922 blocking.join(", ")
923 ));
924 }
925 let mut record = record;
926 let cwd_live = run.as_ref().is_some_and(|found| found.cwd.is_dir());
927 if let Some(found) = &run
928 && cwd_live
929 {
930 remember_cwd(&mut record, &found.cwd);
931 if record.worktree.is_none()
932 && let Some(worktree) =
933 crate::delegate::worktree::worktree_ref(&self.worktrees, &found.cwd)
934 {
935 record.worktree = Some(worktree);
936 }
937 }
938 if record.worktree.is_none()
944 && let Some(found) = &run
945 && let Some(worktree) = served_worktree(&self.jobs, found)
946 {
947 record.worktree = Some(worktree);
948 }
949 if !cwd_live
950 && let Some(found) = &run
951 && let Some(worktree) = &record.worktree
952 && found.cwd.starts_with(Path::new(&worktree.path))
953 {
954 remember_cwd(&mut record, &found.cwd);
955 }
956 let cwd = run
957 .as_ref()
958 .map(|run| run.cwd.clone())
959 .filter(|cwd| cwd.is_dir());
960 let adopted = run
961 .as_ref()
962 .and_then(|run| run.vendor.as_deref().zip(cwd.as_deref()))
963 .and_then(|(vendor, cwd)| agent.adopt(&record.agent, cwd, vendor));
964 let mut arguments = if let Some(handle) = &adopted {
965 json!({
966 "session": handle,
967 "prompt": format!(
968 "[SCV] SCV stopped before your last turn on this task finished (a restart \
969 or a crash), so it was cut off. Check where the work stands (files, git \
970 status, anything still running), then carry on. The task was:\n\n{}",
971 record.prompt
972 ),
973 })
974 } else {
975 rerun_arguments(&record, |option| agent.takes(&record.agent, option))
976 };
977 if let Some(object) = arguments.as_object_mut() {
978 for key in ["timeout_seconds", "no_timeout"] {
979 if let Some(value) = record.arguments.get(key) {
980 object.insert(key.into(), value.clone());
981 }
982 }
983 }
984 let meta = JobMeta {
986 resumed_from: Some(key.to_owned()),
987 worktree: record
988 .worktree
989 .clone()
990 .filter(|worktree| Path::new(&worktree.path).is_dir()),
991 ..JobMeta::default()
992 };
993 let resumed = self.start_with(host, arguments, meta).await?;
994 ledger::update(&self.jobs, session, job, |old| {
995 old.resumed_as = Some(resumed.clone());
996 if !old.state.is_final() {
997 ledger::interrupt(old, "its session ended before it finished");
998 }
999 });
1000 Ok(resumed)
1001 }
1002
1003 pub fn cancel(&self, session: &str, job: &str) -> Result<String, String> {
1005 let jobs = {
1006 let inner = lock(&self.inner);
1007 inner
1008 .sessions
1009 .get(session)
1010 .and_then(|slot| slot.jobs.as_ref())
1011 .and_then(Weak::upgrade)
1012 };
1013 let Some(jobs) = jobs else {
1014 return Err(format!(
1015 "session {session} does not run in this daemon; a job whose session ended \
1016 stopped with it, and `scv agents kill` stops another process's runs"
1017 ));
1018 };
1019 jobs.cancel_for_owner(job)?;
1020 Ok(format!(
1021 "Stopping {job}: its agent and every process it started are stopped; work it \
1022 already wrote stays."
1023 ))
1024 }
1025
1026 pub fn dismiss(&self, session: &str, job: &str) -> Result<String, String> {
1028 let record = ledger::load(&self.jobs, session, job)
1029 .ok_or_else(|| format!("no record of {session}/{job}"))?;
1030 let jobs = lock(&self.inner)
1031 .sessions
1032 .get(session)
1033 .and_then(|slot| slot.jobs.as_ref())
1034 .and_then(Weak::upgrade);
1035 let running = jobs.as_ref().is_some_and(|jobs| {
1036 jobs.live()
1037 .iter()
1038 .any(|live| live.id == job && live.running)
1039 });
1040 if running || (!record.state.is_final() && record.owner.is_alive()) {
1041 return Err(format!("{job} is still running; stop it first"));
1042 }
1043 let elsewhere =
1047 record.owner.is_alive() && ProcessIdentity::current().as_ref() != Some(&record.owner);
1048 if jobs.is_some_and(|jobs| jobs.unreported(job)) || (elsewhere && !record.reported) {
1049 return Err(format!(
1050 "{job}'s result has not reached its session yet; dismiss it once it has"
1051 ));
1052 }
1053 ledger::dismiss(&self.jobs, session, job).map_err(|error| error.to_string())?;
1054 Ok(format!("Forgot {session}/{job}."))
1055 }
1056
1057 pub fn job_record(&self, session: &str, job: &str) -> Option<JobRecord> {
1059 ledger::load(&self.jobs, session, job)
1060 }
1061
1062 pub fn job_result(&self, session: &str, job: &str) -> Result<JobResult, String> {
1065 let record = ledger::load(&self.jobs, session, job)
1066 .ok_or_else(|| format!("no record of {session}/{job}"))?;
1067 let (jobs, awaiting, session_live) = {
1068 let inner = lock(&self.inner);
1069 let slot = inner.sessions.get(session);
1070 (
1071 slot.and_then(|slot| slot.jobs.as_ref())
1072 .and_then(Weak::upgrade),
1073 inner
1074 .approvals
1075 .values()
1076 .find(|pending| pending.info.session == session && pending.info.job == job)
1077 .map(|pending| pending.info.clone()),
1078 slot.is_some(),
1079 )
1080 };
1081 let live = jobs
1082 .as_ref()
1083 .and_then(|jobs| jobs.live().into_iter().find(|live| live.id == job));
1084 let run = activity::list_states(&self.runs)
1086 .into_iter()
1087 .filter(|state| {
1088 state.session == session
1089 && (state.job.as_deref() == Some(job)
1090 || state.jobs.iter().any(|served| served == job))
1091 })
1092 .min_by_key(|state| std::cmp::Reverse((state.last_activity_unix, state.started_unix)));
1093 let (state, detail) = job_state(
1094 &record,
1095 live.as_ref(),
1096 run.as_ref(),
1097 awaiting.as_ref(),
1098 session_live,
1099 unix_now(),
1100 );
1101 let mut truncated = false;
1102 let mut cut = |text: Option<String>| {
1103 text.map(|text| {
1104 let kept = scv_client::text::utf8_prefix(&text, MAX_JOB_RESULT_BYTES);
1105 truncated |= kept.len() < text.len();
1106 kept.to_owned()
1107 })
1108 };
1109 let reply = cut(record.reply);
1110 let error = cut(record.error);
1111 Ok(JobResult {
1112 session: record.session,
1113 job: record.job,
1114 agent: record.agent,
1115 state,
1116 detail,
1117 reply,
1118 error,
1119 truncated,
1120 outcome: jobs.and_then(|jobs| jobs.outcome(job)),
1121 finished_unix_seconds: record.finished_unix,
1122 })
1123 }
1124
1125 pub async fn attach_terminal(
1130 &self,
1131 session: &str,
1132 conversation: &str,
1133 cols: Option<u16>,
1134 rows: Option<u16>,
1135 ) -> Result<AttachPage, String> {
1136 self.take_terminal(session, conversation, cols, rows, false, None)
1137 .await
1138 .map(|(page, _)| page)
1139 }
1140
1141 pub async fn interrupt_and_attach(
1154 &self,
1155 session: &str,
1156 conversation: &str,
1157 cols: Option<u16>,
1158 rows: Option<u16>,
1159 job: Option<&str>,
1160 ) -> Result<(AttachPage, Option<String>), String> {
1161 self.take_terminal(session, conversation, cols, rows, true, job)
1162 .await
1163 }
1164
1165 async fn take_terminal(
1166 &self,
1167 session: &str,
1168 conversation: &str,
1169 cols: Option<u16>,
1170 rows: Option<u16>,
1171 interrupt: bool,
1172 named: Option<&str>,
1173 ) -> Result<(AttachPage, Option<String>), String> {
1174 self.expire_takeovers();
1175 let key = format!("{session}/{conversation}");
1176 let id = format!("at-{}", &uuid::Uuid::new_v4().simple().to_string()[..16]);
1177 if let Some(page) = self.attach_running(&key, &id, cols, rows) {
1178 return Ok((page, None));
1179 }
1180 let _starting = self.attaching.lock().await;
1181 if let Some(page) = self.attach_running(&key, &id, cols, rows) {
1183 return Ok((page, None));
1184 }
1185 if self.closing.load(Ordering::Acquire) {
1186 return Err(stopping());
1187 }
1188 if self.running_takeovers() >= MAX_TAKEOVERS {
1189 return Err(at_cap());
1190 }
1191 let (agent, _) = self.agent(session)?;
1192 let size = takeover::clamp_size(cols, rows);
1193 let (takeover, stopped) = if interrupt {
1194 let reserved = agent.reserve_takeover(conversation)?;
1196 if let (Some(job), Some(named)) = (&reserved.job, named)
1197 && job != named
1198 {
1199 return Err(not_the_named_job(conversation, named, job));
1200 }
1201 if let Some(job) = &reserved.job
1202 && let Some(approval) = lock(&self.inner)
1203 .approvals
1204 .values()
1205 .find(|pending| pending.info.session == session && &pending.info.job == job)
1206 {
1207 return Err(awaits_approval(job, &approval.info.id));
1208 }
1209 let stopped = reserved.job.clone();
1210 (
1211 agent.take_over_reserved(reserved, session, size).await?,
1212 stopped,
1213 )
1214 } else {
1215 (agent.take_over(conversation, session, size).await?, None)
1216 };
1217 let note = stopped.map(|job| format!("Stopped {job} and took {conversation} over."));
1218 let page = takeover.page(&id, 0);
1219 {
1220 let mut takeovers = lock(&self.takeovers);
1221 if !self.closing.load(Ordering::Acquire) {
1222 takeovers.insert(
1223 key,
1224 Held {
1225 takeover,
1226 lease: Some((id, Instant::now())),
1227 closed: None,
1228 },
1229 );
1230 return Ok((page, note));
1231 }
1232 }
1233 takeover.end().await;
1235 Err(stopping())
1236 }
1237
1238 fn attach_running(
1241 &self,
1242 key: &str,
1243 id: &str,
1244 cols: Option<u16>,
1245 rows: Option<u16>,
1246 ) -> Option<AttachPage> {
1247 let (takeover, watched) = {
1248 let mut takeovers = lock(&self.takeovers);
1249 match takeovers.get_mut(key) {
1250 Some(held) if !held.takeover.is_finished() => {
1251 let watched = held.lease.is_some();
1252 held.lease = Some((id.to_owned(), Instant::now()));
1253 (Arc::clone(&held.takeover), watched)
1254 }
1255 Some(_) => {
1256 takeovers.remove(key);
1257 return None;
1258 }
1259 None => return None,
1260 }
1261 };
1262 if !watched {
1263 takeover.note_attached(true);
1264 }
1265 takeover.resize(cols, rows);
1266 Some(takeover.page(id, 0))
1267 }
1268
1269 pub async fn attach_io(
1272 &self,
1273 id: &str,
1274 after: u64,
1275 input: &str,
1276 cols: Option<u16>,
1277 rows: Option<u16>,
1278 ) -> Result<AttachPage, String> {
1279 if input.len() > takeover::MAX_INPUT_BYTES {
1280 return Err(format!(
1281 "at most {} KiB can be typed at once",
1282 takeover::MAX_INPUT_BYTES / 1024
1283 ));
1284 }
1285 self.expire_takeovers();
1286 let takeover = self.leased(id)?;
1287 let note = takeover.input(input.as_bytes()).await;
1288 takeover.resize(cols, rows);
1289 let mut page = takeover.page(id, after);
1290 page.note = note;
1291 Ok(page)
1292 }
1293
1294 pub fn detach(&self, id: &str) -> Result<String, String> {
1296 let takeover = {
1297 let mut takeovers = lock(&self.takeovers);
1298 let held = takeovers
1299 .values_mut()
1300 .find(|held| held.lease.as_ref().is_some_and(|(lease, _)| lease == id))
1301 .ok_or_else(not_attached)?;
1302 held.lease = None;
1303 Arc::clone(&held.takeover)
1304 };
1305 takeover.note_attached(false);
1306 Ok(if takeover.is_running() {
1307 format!(
1308 "Detached from {}; it keeps running in its terminal.",
1309 takeover.conversation
1310 )
1311 } else {
1312 format!(
1313 "Detached from {}, whose terminal has closed.",
1314 takeover.conversation
1315 )
1316 })
1317 }
1318
1319 pub async fn end_takeover(
1323 &self,
1324 session: &str,
1325 conversation: &str,
1326 ) -> Result<(String, AttachPage), String> {
1327 let key = format!("{session}/{conversation}");
1328 let held = lock(&self.takeovers)
1329 .remove(&key)
1330 .ok_or_else(|| format!("{conversation} of {session} is not taken over"))?;
1331 held.takeover.end().await;
1332 let id = held.lease.map(|(id, _)| id).unwrap_or_default();
1333 let page = held.takeover.page(&id, 0);
1334 let how = page.exit.clone().unwrap_or_else(|| "closed".into());
1335 Ok((
1336 format!("Ended the takeover of {conversation}: the terminal closed ({how})."),
1337 page,
1338 ))
1339 }
1340
1341 pub async fn end_takeovers(&self) {
1344 let held: Vec<Held> = {
1345 let mut takeovers = lock(&self.takeovers);
1346 self.closing.store(true, Ordering::Release);
1347 takeovers.drain().map(|(_, held)| held).collect()
1348 };
1349 futures_util::future::join_all(held.iter().map(|held| held.takeover.end())).await;
1350 }
1351
1352 fn running_takeovers(&self) -> usize {
1354 lock(&self.takeovers)
1355 .values()
1356 .filter(|held| !held.takeover.is_finished())
1357 .count()
1358 }
1359
1360 pub fn takeover_sessions(&self) -> HashSet<String> {
1362 lock(&self.takeovers)
1363 .values()
1364 .filter(|held| !held.takeover.is_finished())
1365 .map(|held| held.takeover.session.clone())
1366 .collect()
1367 }
1368
1369 fn end_takeovers_of(&self, session: Option<&str>) {
1372 let ending: Vec<Arc<Takeover>> = {
1373 let mut takeovers = lock(&self.takeovers);
1374 let keys: Vec<String> = takeovers
1375 .iter()
1376 .filter(|(_, held)| session.is_none_or(|session| held.takeover.session == session))
1377 .map(|(key, _)| key.clone())
1378 .collect();
1379 keys.iter()
1380 .filter_map(|key| takeovers.remove(key))
1381 .map(|held| held.takeover)
1382 .collect()
1383 };
1384 if ending.is_empty() {
1385 return;
1386 }
1387 match tokio::runtime::Handle::try_current() {
1388 Ok(runtime) => {
1389 runtime.spawn(async move {
1390 futures_util::future::join_all(ending.iter().map(|takeover| takeover.end()))
1391 .await;
1392 });
1393 }
1394 Err(_) => ending.iter().for_each(|takeover| takeover.kill_now()),
1395 }
1396 }
1397
1398 fn leased(&self, id: &str) -> Result<Arc<Takeover>, String> {
1400 let mut takeovers = lock(&self.takeovers);
1401 let held = takeovers
1402 .values_mut()
1403 .find(|held| held.lease.as_ref().is_some_and(|(lease, _)| lease == id))
1404 .ok_or_else(not_attached)?;
1405 if let Some((_, read)) = &mut held.lease {
1406 *read = Instant::now();
1407 }
1408 Ok(Arc::clone(&held.takeover))
1409 }
1410
1411 fn expire_takeovers(&self) {
1414 let now = Instant::now();
1415 let mut detached = Vec::new();
1416 let mut takeovers = lock(&self.takeovers);
1417 takeovers.retain(|_, held| {
1418 if held.takeover.is_finished() {
1419 let closed = *held.closed.get_or_insert(now);
1420 return now.duration_since(closed) < CLOSED_KEPT;
1421 }
1422 if held
1423 .lease
1424 .as_ref()
1425 .is_some_and(|(_, read)| now.duration_since(*read) >= ATTACH_LEASE)
1426 {
1427 held.lease = None;
1428 detached.push(Arc::clone(&held.takeover));
1429 }
1430 true
1431 });
1432 drop(takeovers);
1433 for takeover in detached {
1434 takeover.note_attached(false);
1435 }
1436 }
1437
1438 pub fn maintain(&self) -> Maintenance {
1441 self.expire_takeovers();
1442 let interrupted_jobs = ledger::settle_orphans(&self.jobs)
1443 .into_iter()
1444 .map(|record| format!("{}/{}", record.session, record.job))
1445 .collect();
1446 let interrupted_runs = activity::settle_orphans(&self.runs);
1447 let pruned = ledger::prune(&self.jobs, KEEP) + activity::prune(&self.runs, KEEP, MAX_RUNS);
1448 Maintenance {
1449 interrupted_jobs,
1450 interrupted_runs,
1451 pruned,
1452 }
1453 }
1454}
1455
1456fn stopping() -> String {
1457 "SCV is stopping, so it takes no conversation over now".into()
1458}
1459
1460fn at_cap() -> String {
1461 format!("{MAX_TAKEOVERS} conversations are taken over already; end one first")
1462}
1463
1464fn awaits_approval(job: &str, id: &str) -> String {
1467 format!(
1468 "{job} waits for your approval ({id}); answer it first (`scv jobs approve` or `deny`). \
1469 Taking its conversation over would withdraw the request, and a takeover never answers \
1470 or withdraws an approval"
1471 )
1472}
1473
1474fn not_the_named_job(conversation: &str, named: &str, running: &str) -> String {
1477 format!(
1478 "{conversation} is running {running}'s turn now, not {named}'s, so nothing was stopped; \
1479 attach again to stop {running}"
1480 )
1481}
1482
1483fn settle_offer(mut offer: AttachOffer, approval: Option<&str>, running: usize) -> AttachOffer {
1487 if offer.interrupt
1488 && let (Some(job), Some(id)) = (&offer.job, approval)
1489 {
1490 offer.detail = Some(awaits_approval(job, id));
1491 offer.interrupt = false;
1492 }
1493 let starts = offer.state == AttachState::Ready || offer.interrupt;
1494 if starts && running >= MAX_TAKEOVERS {
1495 offer.state = AttachState::Full;
1496 offer.detail = Some(at_cap());
1497 offer.interrupt = false;
1498 }
1499 offer
1500}
1501
1502fn not_attached() -> String {
1503 "this terminal is not attached here any more (another dashboard attached to it, or its \
1504 takeover ended); attach again"
1505 .into()
1506}
1507
1508struct Claim<'a> {
1510 claims: &'a Mutex<HashSet<String>>,
1511 key: &'a str,
1512}
1513
1514impl Drop for Claim<'_> {
1515 fn drop(&mut self) {
1516 lock(self.claims).remove(self.key);
1517 }
1518}
1519
1520struct SlotView {
1521 id: String,
1522 label: String,
1523 workspace: PathBuf,
1524 state: AgentState,
1525 detail: Option<String>,
1526 agent: Option<Arc<BackgroundCapable>>,
1527 jobs: Option<Arc<BackgroundJobs>>,
1528}
1529
1530fn job_state(
1533 record: &JobRecord,
1534 live: Option<&LiveJob>,
1535 run: Option<&RunState>,
1536 awaiting: Option<&WorkbenchApproval>,
1537 session_live: bool,
1538 now: u64,
1539) -> (AgentState, Option<String>) {
1540 if record.state.is_final() {
1541 let detail = match record.state {
1542 AgentState::Interrupted => record.interrupted.clone(),
1543 AgentState::Completed => None,
1544 _ => record
1545 .error
1546 .as_deref()
1547 .and_then(|error| error.lines().find(|line| !line.trim().is_empty()))
1548 .map(|line| bounded(line, 200)),
1549 };
1550 return (record.state, detail);
1551 }
1552 if let Some(approval) = awaiting {
1553 return (
1554 AgentState::AwaitingInput,
1555 Some(format!(
1556 "waiting for your approval: {}",
1557 bounded(&approval.summary, 200)
1558 )),
1559 );
1560 }
1561 if let Some(live) = live {
1562 if live.waiting {
1563 let behind = record
1564 .arguments
1565 .get("session")
1566 .and_then(Value::as_str)
1567 .map_or_else(String::new, |handle| format!(" {handle}'s"));
1568 return (
1569 AgentState::Queued,
1570 Some(format!("waits for conversation{behind} running turn")),
1571 );
1572 }
1573 if live.running {
1574 if let Some(run) = run.filter(|run| !run.ended) {
1575 return activity::current(run, now);
1576 }
1577 return (
1578 AgentState::Working,
1579 live.progress
1580 .clone()
1581 .or_else(|| Some(format!("running for {} s", live.elapsed.as_secs()))),
1582 );
1583 }
1584 }
1585 if !record.owner.is_alive() {
1586 return (
1587 AgentState::Interrupted,
1588 Some("the SCV process that ran it stopped".into()),
1589 );
1590 }
1591 if record.owner.pid == std::process::id() && !session_live {
1592 return (
1593 AgentState::Interrupted,
1594 Some("its session ended before it finished".into()),
1595 );
1596 }
1597 match run {
1598 Some(run) if !run.ended => activity::current(run, now),
1599 _ => (record.state, None),
1600 }
1601}
1602
1603pub fn host_session(workspace: &Path) -> String {
1606 use sha2::{Digest, Sha256};
1607 let digest = Sha256::digest(workspace.as_os_str().as_encoded_bytes());
1608 let hex: String = digest[..8]
1609 .iter()
1610 .map(|byte| format!("{byte:02x}"))
1611 .collect();
1612 format!("workbench-{hex}")
1613}
1614
1615fn job_name(session: &str, started: &Value) -> String {
1617 format!(
1618 "{session}/{}",
1619 started.get("job").and_then(Value::as_str).unwrap_or("job")
1620 )
1621}
1622
1623struct AttendedGate {
1626 workbench: Weak<Workbench>,
1627 session: String,
1628 unattended: Arc<dyn ApprovalGate>,
1629}
1630
1631#[async_trait]
1632impl ApprovalGate for AttendedGate {
1633 async fn approve(
1634 &self,
1635 request: ApprovalRequest,
1636 cancellation: CancellationToken,
1637 ) -> Result<bool, AgentError> {
1638 if self
1639 .unattended
1640 .approve(request.clone(), cancellation.clone())
1641 .await?
1642 {
1643 return Ok(true);
1644 }
1645 match self.workbench.upgrade() {
1646 Some(workbench) if workbench.watched() => {
1647 workbench.ask(&self.session, request, &cancellation).await
1648 }
1649 _ => Ok(false),
1650 }
1651 }
1652}
1653
1654fn bounded(text: &str, max: usize) -> String {
1655 if text.chars().count() <= max {
1656 return text.to_owned();
1657 }
1658 let mut cut: String = text.chars().take(max.saturating_sub(1)).collect();
1659 cut.push('…');
1660 cut
1661}
1662
1663fn unix_now() -> u64 {
1664 SystemTime::now()
1665 .duration_since(UNIX_EPOCH)
1666 .map_or(0, |elapsed| elapsed.as_secs())
1667}
1668
1669fn served_job_names(run: &RunState) -> Vec<&str> {
1671 let mut names = Vec::new();
1672 if let Some(job) = run.job.as_deref() {
1673 names.push(job);
1674 }
1675 for job in &run.jobs {
1676 if !names.contains(&job.as_str()) {
1677 names.push(job.as_str());
1678 }
1679 }
1680 names
1681}
1682
1683fn unresumed_served(root: &Path, run: &RunState, skip: &str) -> Vec<String> {
1686 served_job_names(run)
1687 .into_iter()
1688 .filter(|name| *name != skip)
1689 .filter_map(|name| {
1690 let record = ledger::load(root, &run.session, name)?;
1691 record
1692 .resumable()
1693 .then(|| format!("{}/{}", run.session, name))
1694 })
1695 .collect()
1696}
1697
1698fn served_worktree(root: &Path, run: &RunState) -> Option<scv_protocol::WorktreeRef> {
1702 served_job_names(run).into_iter().find_map(|name| {
1703 ledger::load_including_dismissed(root, &run.session, name)
1704 .and_then(|record| record.worktree)
1705 })
1706}
1707
1708fn remember_cwd(record: &mut ledger::JobRecord, cwd: &Path) {
1710 let has_cwd = record
1711 .arguments
1712 .get("cwd")
1713 .and_then(Value::as_str)
1714 .is_some_and(|cwd| !cwd.is_empty());
1715 if !has_cwd && let Some(object) = record.arguments.as_object_mut() {
1716 object.insert("cwd".into(), cwd.display().to_string().into());
1717 }
1718}
1719
1720fn rerun_arguments(record: &JobRecord, takes: impl Fn(&str) -> bool) -> Value {
1727 let mut arguments = record.arguments.clone();
1728 if let Some(object) = arguments.as_object_mut() {
1729 object.remove("session");
1730 object.remove("worktree");
1731 object.remove("on_busy");
1732 for option in ["model", "effort"] {
1735 if !takes(option) {
1736 object.remove(option);
1737 }
1738 }
1739 object.insert("agent".into(), record.agent.clone().into());
1740 object.insert(
1741 "prompt".into(),
1742 format!(
1743 "[SCV] An earlier attempt at this task was cut off when SCV stopped (a restart \
1744 or a crash), and its conversation could not be resumed. It may have left \
1745 partial changes here: check them first, then do the task:\n\n{}",
1746 record.prompt
1747 )
1748 .into(),
1749 );
1750 if let Some(worktree) = &record.worktree {
1751 let top = Path::new(&worktree.path);
1752 let inside = object
1754 .get("cwd")
1755 .and_then(Value::as_str)
1756 .and_then(|cwd| Path::new(cwd).strip_prefix(top).ok())
1757 .map(Path::to_path_buf)
1758 .unwrap_or_default();
1759 if top.is_dir() {
1760 let cwd = Some(top.join(&inside))
1761 .filter(|cwd| cwd.is_dir())
1762 .unwrap_or_else(|| top.to_owned());
1763 object.insert("cwd".into(), cwd.display().to_string().into());
1764 } else {
1765 let repo = Path::new(&worktree.repo);
1766 let cwd = Some(repo.join(&inside))
1767 .filter(|cwd| cwd.is_dir())
1768 .unwrap_or_else(|| repo.to_owned());
1769 object.insert("cwd".into(), cwd.display().to_string().into());
1770 object.insert("worktree".into(), true.into());
1771 }
1772 }
1773 }
1774 arguments
1775}
1776
1777#[cfg(test)]
1778mod tests;