1pub(in crate::delegate) mod lane;
26pub mod ledger;
27mod owed;
28
29use std::{
30 collections::HashSet,
31 path::PathBuf,
32 sync::{Arc, Mutex, Weak},
33 time::{Duration, Instant},
34};
35
36use async_trait::async_trait;
37use scv_core::{
38 ApprovalGate, ProgressSink, Tool, ToolApprovals, ToolContext, ToolError, ToolOutput, ToolRisk,
39 ToolSpec,
40};
41use scv_protocol::{
42 AgentState, JobChange, JobOrigin, JobOutcome, JobReport, JobStatus, describe_reports,
43};
44use serde::Deserialize;
45use serde_json::{Map, Value, json};
46use tokio::sync::{mpsc, watch};
47use tokio_util::sync::CancellationToken;
48
49use self::{
50 lane::{Lanes, Place},
51 ledger::{JobLedger, JobRecord, NewJob},
52};
53use crate::{
54 BusyBehavior,
55 args::{Timeouts, bounded, parse_args, timeout_schema},
56 delegate::{
57 agent::{AGENT_TOOL, AgentTool, Offered},
58 conversation::{self, ConversationStore},
59 output::AgentReply,
60 request::AgentArgs,
61 review::{self, Record, ReviewedTool},
62 },
63 sync::lock,
64};
65
66const MAX_FINISHED: usize = 16;
68const REPORT_REPLY_CHARS: usize = 6000;
70const REPORT_MAX_JOBS: usize = 4;
72const REPORT_RETRY_DELAYS: [Duration; 2] = [Duration::from_secs(30), Duration::from_secs(120)];
76const CANCEL_SETTLE: Duration = Duration::from_secs(10);
78const MAX_PENDING_CHANGES: usize = 64;
81const TASK_CHARS: usize = 80;
83
84pub struct BackgroundJobs {
87 limit: usize,
88 state: Mutex<JobsState>,
89 cancellation: CancellationToken,
90 finished: Option<mpsc::UnboundedSender<()>>,
92 approvals: Option<Arc<dyn ApprovalGate>>,
95 lanes: Arc<Lanes>,
97 ledger: Option<JobLedger>,
99}
100
101impl std::fmt::Debug for BackgroundJobs {
102 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 formatter
104 .debug_struct("BackgroundJobs")
105 .field("limit", &self.limit)
106 .finish_non_exhaustive()
107 }
108}
109
110#[derive(Default)]
111struct JobsState {
112 next: u64,
113 jobs: Vec<Job>,
114 changes: Vec<(String, JobChange)>,
116 owed: owed::Owed,
118}
119
120impl JobsState {
121 fn refresh(&mut self, call_id: &str, outcome: JobOutcome) {
124 if let Some((_, change)) = self
125 .changes
126 .iter_mut()
127 .rev()
128 .find(|(call, change)| call == call_id && change.job == outcome.job)
129 {
130 change.outcome = Some(outcome);
131 }
132 }
133
134 fn record(&mut self, call_id: &str, change: JobChange) {
137 if call_id.is_empty() {
138 return;
139 }
140 if self.changes.len() >= MAX_PENDING_CHANGES {
141 let (call, dropped) = self.changes.remove(0);
142 self.owed.evicted(&call, &dropped.job);
143 }
144 self.changes.push((call_id.to_owned(), change));
145 }
146}
147
148struct Job {
149 id: String,
150 tool: String,
152 agent: String,
154 task: String,
156 started: Instant,
157 progress: ProgressSink,
158 last_progress: Option<String>,
159 cancel: CancellationToken,
161 cancelled: bool,
163 queued: bool,
166 outcome: Option<Outcome>,
167 reported: bool,
172 reporting: bool,
174 report_attempts: u32,
176 report_due: Option<tokio::time::Instant>,
178 done: watch::Receiver<bool>,
179 review: Option<Arc<Record>>,
181 cancelling: bool,
184 origin: JobOrigin,
187 queued_waiting: bool,
189 owner_cancelled: bool,
191 record: Option<JobRecord>,
195 record_revision: u64,
196}
197
198type RecordWrite = (JobRecord, u64);
200
201impl Job {
202 fn record_changed(&mut self) -> Option<RecordWrite> {
204 let record = self.record.as_ref()?;
205 self.record_revision += 1;
206 Some((record.clone(), self.record_revision))
207 }
208}
209
210struct Waiting<'a> {
214 jobs: &'a BackgroundJobs,
215 job: String,
216 call: String,
217 owed: bool,
219 settled: bool,
220}
221
222impl Waiting<'_> {
223 fn settle(mut self) {
228 self.settled = true;
229 let mut state = self.jobs.state();
230 let Some(entry) = state.jobs.iter_mut().find(|entry| entry.id == self.job) else {
231 return;
232 };
233 entry.cancelling = false;
234 if !self.owed {
235 return;
236 }
237 let pending = entry.outcome.is_none().then(|| entry.outcome()).flatten();
238 let pending = pending.map(|mut outcome| {
239 outcome.review.journal_pending = true;
240 outcome
241 });
242 if let Some(outcome) = state.owed.waited(&self.job, &self.call, pending) {
243 state.refresh(&self.call, outcome);
244 }
245 }
246}
247
248impl Drop for Waiting<'_> {
249 fn drop(&mut self) {
250 if self.settled {
251 return;
252 }
253 let mut state = self.jobs.state();
254 if let Some(entry) = state.jobs.iter_mut().find(|entry| entry.id == self.job) {
255 entry.cancelling = false;
256 }
257 if !self.owed {
258 return;
259 }
260 let (call, job) = (&self.call, &self.job);
262 state
263 .changes
264 .retain(|(recorded, change)| !(recorded == call && &change.job == job));
265 let due = state.owed.abandoned(job, call);
266 drop(state);
267 if due {
268 self.jobs.wake();
269 }
270 }
271}
272
273struct Outcome {
274 output: ToolOutput,
275 elapsed: Duration,
276}
277
278#[derive(Debug, PartialEq, Eq)]
280pub enum ReportFailure {
281 Retry(Duration),
283 GiveUp {
286 attempts: u32,
287 reports: Vec<JobReport>,
288 },
289 Settled,
291}
292
293impl Drop for BackgroundJobs {
294 fn drop(&mut self) {
295 self.cancellation.cancel();
296 if let Some(ledger) = &self.ledger {
299 let state = self
300 .state
301 .get_mut()
302 .unwrap_or_else(std::sync::PoisonError::into_inner);
303 for job in state.jobs.iter_mut().filter(|job| job.outcome.is_none()) {
304 if let Some(record) = job.record.as_mut() {
305 ledger::interrupt(record, "its session ended before it finished");
306 ledger.save(record);
307 }
308 }
309 }
310 }
311}
312
313impl BackgroundJobs {
314 pub fn new(limit: usize, finished: Option<mpsc::UnboundedSender<()>>) -> Self {
316 Self {
317 limit,
318 state: Mutex::default(),
319 cancellation: CancellationToken::new(),
320 finished,
321 approvals: None,
322 lanes: Arc::default(),
323 ledger: None,
324 }
325 }
326
327 #[must_use]
330 pub fn with_ledger(mut self, ledger: JobLedger) -> Self {
331 self.state().next = ledger.last_number();
332 self.ledger = Some(ledger);
333 self
334 }
335
336 pub(crate) fn continue_after<'a>(&self, jobs: impl IntoIterator<Item = &'a str>) {
339 let mut state = self.state();
340 for job in jobs {
341 if let Some(number) = job
342 .strip_prefix("job-")
343 .and_then(|number| number.parse::<u64>().ok())
344 {
345 state.next = state.next.max(number);
346 }
347 }
348 }
349
350 #[must_use]
353 pub fn with_approvals(mut self, gate: Arc<dyn ApprovalGate>) -> Self {
354 self.approvals = Some(gate);
355 self
356 }
357
358 pub(crate) fn limit(&self) -> usize {
359 self.limit
360 }
361
362 fn state(&self) -> std::sync::MutexGuard<'_, JobsState> {
363 lock(&self.state)
364 }
365
366 #[allow(
376 clippy::too_many_arguments,
377 reason = "a job's call, its context, its turn, and its review, each its own"
378 )]
379 pub(in crate::delegate) fn start(
380 self: &Arc<Self>,
381 tool: Arc<dyn Tool>,
382 name: &str,
383 agent: &str,
384 arguments: Value,
385 context: &ToolContext,
386 turn: Turn,
387 review: Option<&Arc<ReviewedTool>>,
388 meta: JobMeta,
389 ) -> Result<Value, ToolError> {
390 let JobMeta {
391 origin,
392 worktree,
393 resumed_from,
394 } = meta;
395 let task = task_line(
396 arguments
397 .get("prompt")
398 .and_then(Value::as_str)
399 .unwrap_or_default(),
400 );
401 let queued = matches!(turn, Turn::Queued(_));
402 let reserved = match review {
403 Some(review) => {
404 let id = {
405 let mut state = self.state();
406 self.room(&state, queued)?;
407 state.next += 1;
408 format!("job-{}", state.next)
409 };
410 review.begin(&id)?;
411 Some(id)
412 }
413 None => None,
414 };
415 let (id, progress, done_tx, cancellation, first) = {
416 let mut state = self.state();
417 self.room(&state, queued)?;
419 let id = reserved.unwrap_or_else(|| {
420 state.next += 1;
421 format!("job-{}", state.next)
422 });
423 let progress = ProgressSink::buffered();
424 let (done_tx, done) = watch::channel(false);
425 let cancel = self.cancellation.child_token();
426 let record = self.ledger.as_ref().and_then(|ledger| {
427 let mut record = ledger.record(&NewJob {
428 job: &id,
429 agent,
430 task: &task,
431 arguments: &arguments,
432 started_by: origin,
433 reviewed: review.is_some(),
434 queued,
435 })?;
436 record.worktree.clone_from(&worktree);
437 record.resumed_from = resumed_from;
438 Some(record)
439 });
440 let first = record.clone().map(|record| (record, 1));
441 state.record(
442 &context.call_id,
443 JobChange {
444 job: id.clone(),
445 tool: name.to_owned(),
446 agent: agent.to_owned(),
447 status: JobStatus::Running,
448 task: task.clone(),
449 journal: review.map(|review| review.journal_id()),
450 outcome: None,
451 },
452 );
453 state.jobs.push(Job {
454 id: id.clone(),
455 tool: name.to_owned(),
456 agent: agent.to_owned(),
457 task,
458 started: Instant::now(),
459 progress: progress.clone(),
460 last_progress: None,
461 cancel: cancel.clone(),
462 cancelled: false,
463 queued,
464 outcome: None,
465 reported: false,
466 reporting: false,
467 report_attempts: 0,
468 report_due: None,
469 done,
470 review: review.map(|review| Arc::clone(review.record())),
471 cancelling: false,
472 origin,
473 queued_waiting: queued,
474 owner_cancelled: false,
475 record,
476 record_revision: 1,
477 });
478 (id, progress, done_tx, cancel, first)
479 };
480 self.persist(first);
481 let jobs = Arc::downgrade(self);
482 let job = id.clone();
483 let approvals = self.approvals.clone();
484 let workspace = context.workspace.clone();
485 let reviewed = review.map(Arc::clone);
486 tokio::spawn(async move {
487 let started = Instant::now();
488 let mut context = ToolContext::new(workspace, cancellation);
489 context.background_job = true;
490 context.job = Some(job.clone());
491 if let Some(gate) = &approvals {
494 context.approvals = ToolApprovals::new(Arc::clone(gate), job.clone());
495 }
496 context.progress = progress;
497 let place = turn.place();
498 let output = match &place {
499 Some(place) if queued => {
500 context.progress.report(&format!(
501 "queued: waits for conversation {}'s running turn",
502 place.handle()
503 ));
504 match place.wait_first(&context.cancellation).await {
505 Ok(()) => {
506 if let Some(jobs) = jobs.upgrade() {
507 jobs.began(&job);
508 }
509 context.progress.take();
510 context.progress.report(&format!(
511 "started its turn in conversation {}",
512 place.handle()
513 ));
514 tool.execute(arguments, context).await
515 }
516 Err(error) => match &reviewed {
519 Some(review) => Ok(review.unstarted(&error)),
520 None => Err(error),
521 },
522 }
523 }
524 _ => tool.execute(arguments, context).await,
525 }
526 .unwrap_or_else(ToolOutput::from);
527 drop(place);
530 finish(&jobs, &job, output, started.elapsed());
531 let _ = done_tx.send(true);
532 });
533 let mut started = json!({
534 "job": id,
535 "agent": agent,
536 "status": "running",
537 "background": true,
538 "note": "The agent is working in the background. SCV reports the result in a new \
539 turn when it finishes. agent_status shows its progress and agent_cancel \
540 stops it."
541 });
542 if let Some(worktree) = &worktree {
543 started["worktree"] = serde_json::to_value(worktree).unwrap_or(Value::Null);
544 }
545 if queued {
546 started["queued"] = true.into();
547 started["note"] = "The conversation is busy, so this prompt is queued: it runs in \
548 the background after the turns ahead of it, and SCV reports the result in a new \
549 turn when it finishes. agent_status shows where it stands and agent_cancel \
550 withdraws it."
551 .into();
552 }
553 Ok(started)
554 }
555
556 fn began(&self, id: &str) {
558 let write = {
559 let mut state = self.state();
560 state
561 .jobs
562 .iter_mut()
563 .find(|job| job.id == id)
564 .and_then(|job| {
565 job.queued_waiting = false;
566 let record = job.record.as_mut()?;
567 record.state = AgentState::Working;
568 record.updated_unix = ledger::now();
569 job.record_changed()
570 })
571 };
572 self.persist(write);
573 }
574
575 pub(crate) fn recorded_conversations(&self) -> Vec<String> {
577 self.ledger
578 .as_ref()
579 .and_then(JobLedger::conversations)
580 .unwrap_or_default()
581 }
582
583 pub(crate) fn unreported(&self, job: &str) -> bool {
586 self.state()
587 .jobs
588 .iter()
589 .any(|entry| entry.id == job && !entry.reported)
590 }
591
592 fn mark_reported(job: &mut Job) -> Option<RecordWrite> {
595 let record = job.record.as_mut().filter(|record| !record.reported)?;
596 record.reported = true;
597 record.updated_unix = ledger::now();
598 job.record_changed()
599 }
600
601 fn persist(&self, writes: impl IntoIterator<Item = RecordWrite>) {
606 let Some(ledger) = &self.ledger else {
607 return;
608 };
609 let write = || {
610 for (record, revision) in writes {
611 ledger.save_revision(&record, revision);
612 }
613 };
614 match tokio::runtime::Handle::try_current() {
615 Ok(runtime)
616 if runtime.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread =>
617 {
618 tokio::task::block_in_place(write);
619 }
620 _ => write(),
621 }
622 }
623
624 pub(crate) fn live(&self) -> Vec<LiveJob> {
626 let mut state = self.state();
627 state
628 .jobs
629 .iter_mut()
630 .map(|job| {
631 if let Some(line) = job.progress.take() {
632 job.last_progress = Some(line);
633 }
634 LiveJob {
635 id: job.id.clone(),
636 waiting: job.queued_waiting,
637 running: job.outcome.is_none(),
638 progress: job.last_progress.clone(),
639 elapsed: job.started.elapsed(),
640 }
641 })
642 .collect()
643 }
644
645 pub(crate) fn cancel_for_owner(&self, job: &str) -> Result<(), String> {
650 let mut state = self.state();
651 let entry = state
652 .jobs
653 .iter_mut()
654 .find(|candidate| candidate.id == job)
655 .ok_or_else(|| format!("this session has no job {job:?}"))?;
656 if entry.outcome.is_some() {
657 return Err(format!("{job} has already finished"));
658 }
659 entry.cancelled = true;
660 entry.owner_cancelled = true;
661 entry.cancel.cancel();
662 Ok(())
663 }
664
665 fn has_room(&self) -> Result<(), ToolError> {
668 let state = self.state();
669 self.room(&state, false)
670 }
671
672 fn room(&self, state: &JobsState, queued: bool) -> Result<(), ToolError> {
675 let running = state
676 .jobs
677 .iter()
678 .filter(|job| job.outcome.is_none() && !job.queued)
679 .count();
680 if !queued && running >= self.limit {
681 return Err(ToolError::limit(format!(
682 "{running} background jobs are already running, the limit \
683 (agent.max_background). Start this one after a job finishes, or \
684 stop one with agent_cancel if the user no longer needs it."
685 )));
686 }
687 Ok(())
688 }
689
690 async fn cancel(&self, job: &str, call_id: &str) -> Result<Value, ToolError> {
701 let (mut done, owed, write) = {
702 let mut state = self.state();
703 let index = state
704 .jobs
705 .iter()
706 .position(|candidate| candidate.id == job)
707 .ok_or_else(|| unknown_job(job))?;
708 let entry = &mut state.jobs[index];
709 if entry.origin == JobOrigin::Owner && entry.outcome.is_none() {
710 return Err(ToolError::invalid_arguments(format!(
711 "{job} is the user's own job, started from the SCV workbench; only the \
712 user stops it"
713 )));
714 }
715 if entry.outcome.is_some() {
716 let (mut value, change) = entry.describe();
717 value["note"] = "The job had already finished.".into();
718 let write = change
720 .is_some()
721 .then(|| Self::mark_reported(entry))
722 .flatten();
723 if let Some(change) = change {
724 state.record(call_id, change);
725 }
726 drop(state);
727 self.persist(write);
728 return Ok(value);
729 }
730 entry.cancelled = true;
731 entry.cancel.cancel();
732 entry.cancelling = true;
733 let done = entry.done.clone();
734 let mut owed = false;
735 let mut write = None;
736 if !entry.reported {
737 entry.reported = true;
738 write = Self::mark_reported(entry);
739 let change = entry.change(JobStatus::Cancelled);
740 owed = entry.review.is_some();
741 state.record(call_id, change);
742 if owed {
743 state.owed.cancelling(job, call_id);
744 }
745 }
746 (done, owed, write)
747 };
748 self.persist(write);
749 let waiting = Waiting {
750 jobs: self,
751 job: job.to_owned(),
752 call: call_id.to_owned(),
753 owed,
754 settled: false,
755 };
756 let _ = tokio::time::timeout(CANCEL_SETTLE, done.wait_for(|finished| *finished)).await;
757 waiting.settle();
758 self.describe(Some(job), call_id)
759 }
760
761 pub fn take_updates(&self) -> Vec<JobOutcome> {
765 self.state().owed.take_updates()
766 }
767
768 pub fn updated(&self, jobs: &[String], delivered: bool) {
771 self.state().owed.updated(jobs, delivered);
772 }
773
774 pub fn published(&self, call_id: &str, delivered: bool) {
780 let due = self.state().owed.sent(call_id, delivered);
781 if due {
782 self.wake();
783 }
784 }
785
786 pub fn turn_ended(&self) {
789 let due = self.state().owed.turn_ended();
790 if due {
791 self.wake();
792 }
793 }
794
795 #[cfg(any(test, feature = "test-support"))]
802 pub fn owe_for_tests(&self, call_id: &str, change: JobChange, last: Option<JobOutcome>) {
803 let mut state = self.state();
804 state.owed.owe(call_id, &change, last);
805 state.record(call_id, change);
806 }
807
808 #[cfg(any(test, feature = "test-support"))]
810 pub fn stop_for_tests(&self, outcome: &JobOutcome) {
811 let due = self.state().owed.stopped(&outcome.job, outcome);
812 if due {
813 self.wake();
814 }
815 }
816
817 #[cfg(any(test, feature = "test-support"))]
821 pub fn start_for_tests(self: &Arc<Self>, agent: &str) -> tokio::sync::oneshot::Sender<()> {
822 struct Waits(Mutex<Option<tokio::sync::oneshot::Receiver<()>>>);
823 #[async_trait]
824 impl Tool for Waits {
825 fn spec(&self) -> ToolSpec {
826 ToolSpec {
827 name: AGENT_TOOL.into(),
828 description: String::new(),
829 parameters: json!({"type":"object"}),
830 }
831 }
832 fn risk(&self, _arguments: &Value) -> Result<ToolRisk, ToolError> {
833 Ok(ToolRisk::Delegate)
834 }
835 fn approval_summary(&self, _arguments: &Value) -> Result<String, ToolError> {
836 Ok(String::new())
837 }
838 async fn execute(
839 &self,
840 _arguments: Value,
841 _context: ToolContext,
842 ) -> Result<ToolOutput, ToolError> {
843 let release = lock(&self.0).take();
844 if let Some(release) = release {
845 let _ = release.await;
846 }
847 Ok(ToolOutput::success(
848 r#"{"status":"completed","reply":"done"}"#,
849 ))
850 }
851 }
852 let (release, waiting) = tokio::sync::oneshot::channel();
853 let context = ToolContext::new(std::env::temp_dir(), CancellationToken::new());
854 self.start(
855 Arc::new(Waits(Mutex::new(Some(waiting)))),
856 AGENT_TOOL,
857 agent,
858 json!({"prompt": "wait"}),
859 &context,
860 Turn::New,
861 None,
862 JobMeta::default(),
863 )
864 .expect("a test job starts");
865 release
866 }
867
868 fn wake(&self) {
870 if let Some(finished) = &self.finished {
871 let _ = finished.send(());
872 }
873 }
874
875 async fn wait(
878 &self,
879 job: &str,
880 limit: Duration,
881 cancellation: &CancellationToken,
882 call_id: &str,
883 ) -> Result<Value, ToolError> {
884 let mut done = self
885 .state()
886 .jobs
887 .iter()
888 .find(|candidate| candidate.id == job)
889 .map(|candidate| candidate.done.clone())
890 .ok_or_else(|| unknown_job(job))?;
891 tokio::select! {
892 () = cancellation.cancelled() => return Err(ToolError::cancelled("wait cancelled")),
893 _ = tokio::time::timeout(limit, done.wait_for(|finished| *finished)) => {}
894 }
895 self.describe(Some(job), call_id)
896 }
897
898 fn describe(&self, job: Option<&str>, call_id: &str) -> Result<Value, ToolError> {
901 let mut state = self.state();
902 let mut changes = Vec::new();
903 let mut writes = Vec::new();
904 let value = if let Some(job) = job {
905 let entry = state
906 .jobs
907 .iter_mut()
908 .find(|candidate| candidate.id == job)
909 .ok_or_else(|| unknown_job(job))?;
910 let (value, change) = entry.describe();
911 if change.is_some() {
912 writes.extend(Self::mark_reported(entry));
913 }
914 changes.extend(change);
915 value
916 } else {
917 let jobs: Vec<Value> = state
918 .jobs
919 .iter_mut()
920 .map(|entry| {
921 let (value, change) = entry.describe();
922 if change.is_some() {
923 writes.extend(Self::mark_reported(entry));
924 }
925 changes.extend(change);
926 value
927 })
928 .collect();
929 json!({ "jobs": jobs })
930 };
931 for change in changes {
932 state.record(call_id, change);
933 }
934 drop(state);
935 self.persist(writes);
936 Ok(value)
937 }
938
939 pub fn take_changes(&self, call_id: &str) -> Vec<JobChange> {
945 let mut state = self.state();
946 let mut taken = Vec::new();
947 state.changes.retain(|(call, change)| {
948 if call == call_id {
949 taken.push(change.clone());
950 false
951 } else {
952 true
953 }
954 });
955 for change in &mut taken {
956 state.owed.take(call_id, change);
957 }
958 taken
959 }
960
961 pub fn take_unreported(&self) -> Vec<JobReport> {
966 let now = tokio::time::Instant::now();
967 let mut state = self.state();
968 state
969 .jobs
970 .iter_mut()
971 .filter(|job| job.awaits_report() && job.report_due.is_none_or(|due| due <= now))
972 .take(REPORT_MAX_JOBS)
973 .filter_map(|job| {
974 job.reporting = true;
975 job.report()
976 })
977 .collect()
978 }
979
980 pub fn report_settled(&self, jobs: &[String]) {
983 let mut writes = Vec::new();
984 {
985 let mut state = self.state();
986 for job in state.jobs.iter_mut().filter(|job| jobs.contains(&job.id)) {
987 job.reporting = false;
988 job.reported = true;
989 job.report_due = None;
990 writes.extend(Self::mark_reported(job));
991 }
992 }
993 self.persist(writes);
994 }
995
996 pub fn report_failed(&self, jobs: &[String], retry: bool) -> ReportFailure {
1003 let mut writes = Vec::new();
1004 let failure = self.report_failed_locked(jobs, retry, &mut writes);
1005 self.persist(writes);
1006 failure
1007 }
1008
1009 fn report_failed_locked(
1012 &self,
1013 jobs: &[String],
1014 retry: bool,
1015 writes: &mut Vec<RecordWrite>,
1016 ) -> ReportFailure {
1017 let mut state = self.state();
1018 let mut failed: Vec<&mut Job> = state
1019 .jobs
1020 .iter_mut()
1021 .filter(|job| job.reporting && jobs.contains(&job.id))
1022 .collect();
1023 for job in &mut failed {
1024 job.reporting = false;
1025 job.report_attempts += 1;
1026 }
1027 failed.retain(|job| !job.reported);
1029 let Some(attempts) = failed.iter().map(|job| job.report_attempts).max() else {
1030 return ReportFailure::Settled;
1031 };
1032 let delay = usize::try_from(attempts - 1)
1033 .ok()
1034 .and_then(|earlier| REPORT_RETRY_DELAYS.get(earlier));
1035 if retry && let Some(&delay) = delay {
1036 let due = tokio::time::Instant::now() + delay;
1037 for job in failed {
1038 job.report_due = Some(due);
1039 }
1040 return ReportFailure::Retry(delay);
1041 }
1042 let reports = failed
1043 .into_iter()
1044 .filter_map(|job| {
1045 job.reported = true;
1046 job.report_due = None;
1047 writes.extend(Self::mark_reported(job));
1048 job.report()
1049 })
1050 .collect();
1051 ReportFailure::GiveUp { attempts, reports }
1052 }
1053
1054 pub fn retry_now(&self) -> bool {
1058 let mut state = self.state();
1059 let mut waiting = false;
1060 for job in state.jobs.iter_mut().filter(|job| job.awaits_report()) {
1061 waiting |= job.report_due.take().is_some();
1062 }
1063 waiting
1064 }
1065
1066 pub fn next_retry(&self) -> Option<tokio::time::Instant> {
1069 self.state()
1070 .jobs
1071 .iter()
1072 .filter(|job| job.awaits_report())
1073 .filter_map(|job| job.report_due)
1074 .min()
1075 }
1076
1077 pub fn pending(&self) -> usize {
1082 let state = self.state();
1083 let mut pending: HashSet<&str> = state
1084 .jobs
1085 .iter()
1086 .filter(|job| job.outcome.is_none() || !job.reported)
1087 .map(|job| job.id.as_str())
1088 .collect();
1089 pending.extend(state.owed.jobs());
1090 pending.len()
1091 }
1092
1093 pub fn running(&self) -> usize {
1095 self.state()
1096 .jobs
1097 .iter()
1098 .filter(|job| job.outcome.is_none())
1099 .count()
1100 }
1101}
1102
1103#[derive(Debug, Clone, Default)]
1106pub(crate) struct JobMeta {
1107 pub(crate) origin: JobOrigin,
1108 pub(crate) worktree: Option<scv_protocol::WorktreeRef>,
1109 pub(crate) resumed_from: Option<String>,
1111}
1112
1113#[derive(Debug, Clone, PartialEq)]
1115pub(crate) enum OwnerCall {
1116 Job(Value),
1118 Steered,
1120}
1121
1122#[derive(Debug, Clone)]
1124pub(crate) struct LiveJob {
1125 pub(crate) id: String,
1126 pub(crate) waiting: bool,
1128 pub(crate) running: bool,
1129 pub(crate) progress: Option<String>,
1130 pub(crate) elapsed: Duration,
1131}
1132
1133pub(crate) fn agent_state(status: JobStatus) -> AgentState {
1135 match status {
1136 JobStatus::Completed => AgentState::Completed,
1137 JobStatus::Declined => AgentState::Declined,
1138 JobStatus::Timeout => AgentState::Timeout,
1139 JobStatus::Cancelled => AgentState::Cancelled,
1140 JobStatus::Running => AgentState::Working,
1141 _ => AgentState::Failed,
1142 }
1143}
1144
1145pub(in crate::delegate) enum Turn {
1147 New,
1149 Next(Place),
1151 Queued(Place),
1153}
1154
1155impl Turn {
1156 fn place(self) -> Option<Place> {
1157 match self {
1158 Self::New => None,
1159 Self::Next(place) | Self::Queued(place) => Some(place),
1160 }
1161 }
1162}
1163
1164fn finish(jobs: &Weak<BackgroundJobs>, id: &str, output: ToolOutput, elapsed: Duration) {
1165 let Some(jobs) = jobs.upgrade() else {
1167 return;
1168 };
1169 let mut write = None;
1170 {
1171 let mut state = jobs.state();
1172 let mut last = None;
1173 if let Some(job) = state.jobs.iter_mut().find(|job| job.id == id) {
1174 job.last_progress = job.progress.take().or(job.last_progress.take());
1175 job.queued_waiting = false;
1176 job.reported |=
1179 (job.cancelled && !job.owner_cancelled) || job.origin == JobOrigin::Owner;
1180 let result = result_value(&output);
1181 let status = if job.cancelled {
1182 JobStatus::Cancelled
1183 } else {
1184 job_status(&output, &AgentReply::read(&result).unwrap_or_default())
1185 };
1186 job.outcome = Some(Outcome { output, elapsed });
1187 if let Some(record) = job.record.as_mut() {
1188 ledger::finish(record, agent_state(status), &result, job.reported);
1189 write = job.record_changed();
1190 }
1191 last = job.outcome();
1192 }
1193 if let Some(last) = last {
1195 state.owed.stopped(id, &last);
1196 }
1197 let finished = state
1199 .jobs
1200 .iter()
1201 .filter(|job| job.outcome.is_some())
1202 .count();
1203 let mut excess = finished.saturating_sub(MAX_FINISHED);
1204 state.jobs.retain(|job| {
1205 if excess > 0 && job.outcome.is_some() && job.reported && !job.cancelling {
1206 excess -= 1;
1207 false
1208 } else {
1209 true
1210 }
1211 });
1212 }
1213 jobs.persist(write);
1215 if let Some(finished) = &jobs.finished {
1216 let _ = finished.send(());
1217 }
1218}
1219
1220impl Job {
1221 fn awaits_report(&self) -> bool {
1223 self.outcome.is_some() && !self.reported && !self.reporting
1224 }
1225
1226 fn report(&self) -> Option<JobReport> {
1228 let outcome = self.outcome.as_ref()?;
1229 let reply = AgentReply::read(&result_value(&outcome.output)).unwrap_or_default();
1230 Some(JobReport {
1231 job: self.id.clone(),
1232 agent: self.agent.clone(),
1233 task: self.task.clone(),
1234 status: if self.cancelled {
1236 JobStatus::Cancelled
1237 } else {
1238 job_status(&outcome.output, &reply)
1239 },
1240 session: reply.session,
1241 reply: bounded(
1242 reply
1243 .reply
1244 .as_deref()
1245 .unwrap_or(outcome.output.content.as_str()),
1246 REPORT_REPLY_CHARS,
1247 ),
1248 outcome: self.outcome(),
1249 })
1250 }
1251
1252 fn outcome(&self) -> Option<JobOutcome> {
1255 self.review.as_ref().map(|record| {
1256 let (review, landing) = record
1257 .live
1258 .snapshot(self.cancelled || self.cancel.is_cancelled());
1259 JobOutcome {
1260 job: self.id.clone(),
1261 review,
1262 landing,
1263 }
1264 })
1265 }
1266
1267 fn change(&self, status: JobStatus) -> JobChange {
1270 JobChange {
1271 job: self.id.clone(),
1272 tool: self.tool.clone(),
1273 agent: self.agent.clone(),
1274 status,
1275 task: self.task.clone(),
1276 journal: None,
1277 outcome: if status == JobStatus::Running {
1278 None
1279 } else {
1280 self.outcome()
1281 },
1282 }
1283 }
1284
1285 fn describe(&mut self) -> (Value, Option<JobChange>) {
1288 if let Some(line) = self.progress.take() {
1289 self.last_progress = Some(line);
1290 }
1291 let mut change = None;
1292 let mut value = Map::new();
1293 value.insert("job".into(), self.id.clone().into());
1294 value.insert("agent".into(), self.agent.clone().into());
1295 if self.origin == JobOrigin::Owner {
1296 value.insert("started_by".into(), "owner".into());
1297 }
1298 match &self.outcome {
1299 None => {
1300 value.insert("status".into(), "running".into());
1301 value.insert(
1302 "elapsed_seconds".into(),
1303 self.started.elapsed().as_secs().into(),
1304 );
1305 if let Some(progress) = &self.last_progress {
1306 value.insert("progress".into(), progress.clone().into());
1307 }
1308 if let Some(record) = &self.review {
1309 value.insert("review".into(), record.live.status());
1310 }
1311 }
1312 Some(outcome) => {
1313 let result = result_value(&outcome.output);
1314 let status = if self.cancelled {
1315 JobStatus::Cancelled
1316 } else {
1317 job_status(
1318 &outcome.output,
1319 &AgentReply::read(&result).unwrap_or_default(),
1320 )
1321 };
1322 value.insert("status".into(), status.as_str().into());
1323 value.insert("elapsed_seconds".into(), outcome.elapsed.as_secs().into());
1324 value.insert("result".into(), result);
1325 if !self.reported {
1326 self.reported = true;
1327 change = Some(self.change(status));
1328 }
1329 }
1330 }
1331 (Value::Object(value), change)
1332 }
1333}
1334
1335fn result_value(output: &ToolOutput) -> Value {
1337 match serde_json::from_str::<Value>(&output.content) {
1338 Ok(value @ Value::Object(_)) => value,
1339 _ => json!({ "reply": output.content, "is_error": output.is_error() }),
1340 }
1341}
1342
1343fn job_status(output: &ToolOutput, reply: &AgentReply) -> JobStatus {
1346 reply.status.unwrap_or(if output.is_error() {
1347 JobStatus::Failed
1348 } else {
1349 JobStatus::Completed
1350 })
1351}
1352
1353fn task_line(prompt: &str) -> String {
1356 let line = prompt
1357 .lines()
1358 .map(str::trim)
1359 .find(|line| !line.is_empty())
1360 .unwrap_or_default();
1361 let mut task: String = line
1362 .chars()
1363 .filter(|character| !character.is_control())
1364 .take(TASK_CHARS)
1365 .collect();
1366 if line.chars().count() > TASK_CHARS {
1367 task.push('…');
1368 }
1369 task
1370}
1371
1372fn unknown_job(job: &str) -> ToolError {
1373 ToolError::invalid_arguments(format!(
1374 "unknown background job {:?}; agent_status lists this session's jobs",
1375 bounded(job, 64)
1376 ))
1377}
1378
1379const REVIEWED_NOTE: &str = " SCV shows the user its own Review and Landing lines for \
1382 reviewed jobs. If you mention the outcome, repeat them as given; never call unapproved \
1383 work approved or done, and never call landed work approved. If a reviewer declined, tell \
1384 the user what it said. If a landing is NOT confirmed, or the reviewer could NOT confirm \
1385 it, say so plainly, and don't fix, revert, or land anything on your own.";
1386
1387fn reviewed_note(reports: &[JobReport]) -> &'static str {
1388 if reports.iter().any(|report| report.outcome.is_some()) {
1389 REVIEWED_NOTE
1390 } else {
1391 ""
1392 }
1393}
1394
1395pub fn report_prompt(reports: &[JobReport]) -> String {
1397 format!(
1398 "[SCV background report] Delegated work you started in the background has \
1399 finished. The user did not send this message: tell them briefly what \
1400 happened and the key result.{}{}\n\n{}",
1401 cancelled_note(reports),
1402 reviewed_note(reports),
1403 describe_reports(reports)
1404 )
1405}
1406
1407fn cancelled_note(reports: &[JobReport]) -> &'static str {
1410 if reports
1411 .iter()
1412 .any(|report| report.status == JobStatus::Cancelled)
1413 {
1414 " A job reported as cancelled was stopped by the user (from the SCV workbench or \
1415 `scv agents kill`), not by you; don't restart it unless they ask."
1416 } else {
1417 ""
1418 }
1419}
1420
1421pub fn delivered_note(reports: &[JobReport], error: &str) -> String {
1424 format!(
1425 "[SCV background report, already delivered] Delegated work you started in \
1426 the background has finished. Your report of it failed ({error}), so SCV \
1427 sent the user the results below directly. The user did not send this \
1428 message; repeat the results only if they ask.{}\n\n{}",
1429 reviewed_note(reports),
1430 describe_reports(reports)
1431 )
1432}
1433
1434pub(crate) struct BackgroundCapable {
1437 pub(crate) inner: Arc<AgentTool>,
1438 pub(crate) jobs: Arc<BackgroundJobs>,
1439 pub(crate) conversations: Arc<ConversationStore>,
1441 pub(crate) reviews: Option<PathBuf>,
1444 pub(crate) session: Option<String>,
1446}
1447
1448struct QueuedTool(Arc<AgentTool>);
1451
1452#[async_trait]
1453impl Tool for QueuedTool {
1454 fn spec(&self) -> ToolSpec {
1455 self.0.spec()
1456 }
1457 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
1458 self.0.risk(arguments)
1459 }
1460 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
1461 self.0.approval_summary(arguments)
1462 }
1463 async fn execute(
1464 &self,
1465 arguments: Value,
1466 context: ToolContext,
1467 ) -> Result<ToolOutput, ToolError> {
1468 self.0.execute_when_idle(arguments, context).await
1469 }
1470}
1471
1472struct Split {
1474 arguments: Value,
1475 background: Option<bool>,
1477 review: Option<Value>,
1479}
1480
1481impl Split {
1482 fn new(arguments: &Value) -> Self {
1483 let mut arguments = arguments.clone();
1484 let object = arguments.as_object_mut();
1485 let (background, review) = match object {
1486 Some(object) => (
1487 object
1488 .remove("background")
1489 .map(|value| value.as_bool() == Some(true)),
1490 object.remove("review").filter(|review| !review.is_null()),
1491 ),
1492 None => (None, None),
1493 };
1494 Self {
1495 arguments,
1496 background,
1497 review,
1498 }
1499 }
1500
1501 fn background(&self) -> bool {
1502 self.background == Some(true)
1503 }
1504}
1505
1506#[async_trait]
1507impl Tool for BackgroundCapable {
1508 fn spec(&self) -> ToolSpec {
1509 let mut spec = self.inner.spec();
1510 let reviewed = self.offers_review();
1511 if let Some(properties) = spec
1512 .parameters
1513 .get_mut("properties")
1514 .and_then(Value::as_object_mut)
1515 {
1516 properties.insert(
1517 "background".into(),
1518 json!({
1519 "type":"boolean",
1520 "description":"Run in the background: the call returns a job handle at once, \
1521 the user can keep talking to you while the agent works, and SCV reports \
1522 the result in a new turn when it finishes. Use it for any substantial \
1523 task; run in the foreground only for quick work whose result you need \
1524 within this turn."
1525 }),
1526 );
1527 if reviewed {
1528 properties.insert("review".into(), review::schema(&self.inner));
1529 }
1530 }
1531 spec.description.push_str(&format!(
1532 " Set background to true for anything beyond a quick task: the call returns a job \
1533 handle at once (at most {} running per session), SCV reports the result when the \
1534 job finishes, agent_status shows progress, and agent_cancel stops it. A background \
1535 job's own approval requests get only the answer this session would give without \
1536 asking a person.",
1537 self.jobs.limit()
1538 ));
1539 if reviewed {
1540 spec.description.push_str(
1541 " A call with review runs as a reviewed background job: an independent \
1542 reviewer checks the work in up to review.rounds rounds.",
1543 );
1544 }
1545 spec
1546 }
1547
1548 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
1549 let split = Split::new(arguments);
1550 if let Some(review) = &split.review {
1551 self.plan(&split, review)?;
1552 }
1553 self.inner.risk_for(
1554 &split.arguments,
1555 split.background() || split.review.is_some(),
1556 )
1557 }
1558
1559 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
1560 let split = Split::new(arguments);
1561 let background = split.background() || split.review.is_some();
1562 let mut summary = self
1563 .inner
1564 .approval_summary_for(&split.arguments, background)?;
1565 if let Some(review) = &split.review {
1566 let plan = self.plan(&split, review)?;
1567 summary.push_str(
1568 " Runs in the background: the call returns at once and the result is \
1569 reported when the review ends.",
1570 );
1571 summary.push_str(&review::approval_summary(&plan, &self.inner)?);
1572 } else if split.background() {
1573 summary.push_str(
1574 " Runs in the background: the call returns at once and the result is \
1575 reported when the agent finishes.",
1576 );
1577 }
1578 Ok(summary)
1579 }
1580
1581 async fn execute(
1582 &self,
1583 arguments: Value,
1584 context: ToolContext,
1585 ) -> Result<ToolOutput, ToolError> {
1586 let split = Split::new(&arguments);
1587 if let Some(review) = &split.review {
1588 return self.start_reviewed(&split, review, &context).await;
1589 }
1590 let background = split.background();
1591 let mut arguments = split.arguments;
1592 self.inner.route(&arguments)?;
1593 self.inner.risk_for(&arguments, background)?;
1597 let worktree = if background && parse_args::<AgentArgs>(&arguments)?.worktree {
1601 self.jobs.has_room()?;
1602 self.inner
1603 .prepare_worktree(&mut arguments, &context)
1604 .await?
1605 } else {
1606 None
1607 };
1608 let (agent, routed) = self.inner.route(&arguments)?;
1609 let Some(handle) = parse_args::<AgentArgs>(&routed)?
1610 .session
1611 .filter(|session| conversation::is_handle(session))
1612 else {
1613 return if background {
1614 let started = self.start_as(
1615 agent,
1616 arguments,
1617 &context,
1618 Turn::New,
1619 JobMeta {
1620 origin: JobOrigin::Model,
1621 worktree,
1622 resumed_from: None,
1623 },
1624 )?;
1625 Ok(ToolOutput::success(started.to_string()))
1626 } else {
1627 self.inner.execute(arguments, context).await
1628 };
1629 };
1630 let place = self.jobs.lanes.join(&handle);
1632 if place.ahead == 0 && !agent.backend.busy(&routed)? {
1633 return if background {
1634 self.start(agent, arguments, &context, Turn::Next(place))
1635 } else {
1636 let output = self.inner.execute(arguments, context).await;
1638 drop(place);
1639 output
1640 };
1641 }
1642 let behavior = match agent.on_busy(&routed)? {
1643 BusyBehavior::Steer => {
1644 if let Some(output) = agent.backend.steer(routed, context.clone()).await? {
1645 return Ok(output);
1646 }
1647 agent.busy.fallback()
1648 }
1649 behavior => behavior,
1650 };
1651 match behavior {
1652 BusyBehavior::Wait if !background => {
1654 place.wait_first(&context.cancellation).await?;
1655 let output = self.inner.execute_when_idle(arguments, context).await;
1656 drop(place);
1657 output
1658 }
1659 BusyBehavior::Fail => Err(busy(&handle, &place)),
1660 _ => {
1661 queue_room(agent, &handle, &place)?;
1662 self.start(agent, arguments, &context, Turn::Queued(place))
1663 }
1664 }
1665 }
1666}
1667
1668fn busy(handle: &str, place: &Place) -> ToolError {
1671 let waiting = place.ahead.saturating_sub(1);
1673 ToolError::failed(if waiting == 0 {
1674 format!("session busy: conversation {handle} is still running a turn")
1675 } else {
1676 format!(
1677 "session busy: conversation {handle} is still running a turn, and {waiting} more \
1678 prompts wait for it"
1679 )
1680 })
1681}
1682
1683fn queue_room(agent: &Offered, handle: &str, place: &Place) -> Result<(), ToolError> {
1685 let waiting = place.ahead.saturating_sub(1);
1686 if waiting >= agent.busy.max_queued_turns {
1687 return Err(ToolError::limit(format!(
1688 "session busy: conversation {handle} is running a turn and {waiting} prompts \
1689 already wait for it, of the {} agent.max_queued_turns allows. Send this one \
1690 once they have run, or stop one with agent_cancel.",
1691 agent.busy.max_queued_turns
1692 )));
1693 }
1694 Ok(())
1695}
1696
1697impl BackgroundCapable {
1698 #[cfg(test)]
1701 pub(crate) fn plain(inner: Arc<AgentTool>, jobs: Arc<BackgroundJobs>) -> Self {
1702 Self {
1703 inner,
1704 jobs,
1705 conversations: Arc::new(ConversationStore::new(
1706 conversation::ConversationLimits {
1707 max: 8,
1708 idle: Duration::from_secs(86400),
1709 },
1710 None,
1711 )),
1712 reviews: None,
1713 session: None,
1714 }
1715 }
1716
1717 fn offers_review(&self) -> bool {
1721 self.reviews.is_some() && !self.inner.continuing().is_empty()
1722 }
1723
1724 fn plan(&self, split: &Split, review: &Value) -> Result<review::Plan, ToolError> {
1726 if !self.offers_review() {
1727 return Err(ToolError::invalid_arguments(
1728 "review is not available in this session",
1729 ));
1730 }
1731 if split.background == Some(false) {
1732 return Err(ToolError::invalid_arguments(
1733 "a reviewed call runs as a background job; omit background or set it to true",
1734 ));
1735 }
1736 let (builder, routed) = self.inner.route(&split.arguments)?;
1737 builder.backend.risk(&routed)?;
1738 review::plan(
1739 &self.inner,
1740 builder,
1741 &routed,
1742 review,
1743 self.conversations.limits().max,
1744 )
1745 }
1746
1747 async fn start_reviewed(
1752 &self,
1753 split: &Split,
1754 review: &Value,
1755 context: &ToolContext,
1756 ) -> Result<ToolOutput, ToolError> {
1757 let mut plan = self.plan(split, review)?;
1758 let mut arguments = split.arguments.clone();
1759 self.inner.risk_for(&arguments, true)?;
1760 let worktree = if parse_args::<AgentArgs>(&arguments)?.worktree {
1762 self.jobs.has_room()?;
1763 let worktree = self.inner.prepare_worktree(&mut arguments, context).await?;
1764 plan.cwd = arguments
1765 .get("cwd")
1766 .and_then(Value::as_str)
1767 .map(str::to_owned);
1768 worktree
1769 } else {
1770 None
1771 };
1772 let (agent, routed) = self.inner.route(&arguments)?;
1773 let turn = match parse_args::<AgentArgs>(&routed)?
1774 .session
1775 .filter(|session| conversation::is_handle(session))
1776 {
1777 None => Turn::New,
1778 Some(handle) => {
1779 let place = self.jobs.lanes.join(&handle);
1780 if place.ahead == 0 && !agent.backend.busy(&routed)? {
1781 Turn::Next(place)
1782 } else if agent.on_busy(&routed)? == BusyBehavior::Fail {
1783 return Err(busy(&handle, &place));
1784 } else {
1785 queue_room(agent, &handle, &place)?;
1786 Turn::Queued(place)
1787 }
1788 }
1789 };
1790 let dir = self.reviews.as_deref().ok_or_else(|| {
1791 ToolError::invalid_arguments("review is not available in this session")
1792 })?;
1793 let reviewers: Vec<String> = plan
1794 .reviewer_names()
1795 .into_iter()
1796 .map(str::to_owned)
1797 .collect();
1798 let (rounds, land) = (plan.rounds, plan.land);
1799 let tool = Arc::new(ReviewedTool::new(
1800 Arc::clone(&self.inner),
1801 Arc::clone(&self.jobs.lanes),
1802 Arc::clone(&self.conversations),
1803 plan,
1804 arguments.clone(),
1805 self.session.clone(),
1806 dir,
1807 )?);
1808 let queued = matches!(turn, Turn::Queued(_));
1809 let mut started = self.jobs.start(
1810 Arc::clone(&tool) as Arc<dyn Tool>,
1811 AGENT_TOOL,
1812 &agent.name,
1813 arguments,
1814 context,
1815 turn,
1816 Some(&tool),
1817 JobMeta {
1818 origin: JobOrigin::Model,
1819 worktree,
1820 resumed_from: None,
1821 },
1822 )?;
1823 let first = reviewers.first().cloned().unwrap_or_default();
1824 started["review"] = json!({
1825 "reviewers": reviewers,
1826 "rounds": rounds,
1827 "land": review::land_name(land),
1828 "journal": tool.journal_id(),
1829 });
1830 started["note"] = format!(
1831 "{}The agent works on it, then a fresh {first} reviews it (or the next reviewer \
1832 available), for up to {rounds} rounds. SCV reports the result, with its own Review \
1833 and Landing lines, in a new turn when the review ends. agent_status shows the \
1834 round and phase; agent_cancel stops the whole review.",
1835 if queued {
1836 "The conversation is busy, so this reviewed call is queued behind the turns \
1837 ahead of it. "
1838 } else {
1839 ""
1840 }
1841 )
1842 .into();
1843 Ok(ToolOutput::success(started.to_string()))
1844 }
1845
1846 fn start(
1848 &self,
1849 agent: &Offered,
1850 mut arguments: Value,
1851 context: &ToolContext,
1852 turn: Turn,
1853 ) -> Result<ToolOutput, ToolError> {
1854 let meta = self.queued_meta(&mut arguments, &turn);
1855 let started = self.start_as(agent, arguments, context, turn, meta)?;
1856 Ok(ToolOutput::success(started.to_string()))
1857 }
1858
1859 fn queued_meta(&self, arguments: &mut Value, turn: &Turn) -> JobMeta {
1864 if !matches!(turn, Turn::Queued(_)) {
1865 return JobMeta::default();
1866 }
1867 let Some(handle) = parse_args::<AgentArgs>(arguments)
1868 .ok()
1869 .and_then(|args| args.session)
1870 .filter(|session| conversation::is_handle(session))
1871 else {
1872 return JobMeta::default();
1873 };
1874 let Some(cwd) = self.conversations.cwd(&handle) else {
1875 return JobMeta::default();
1876 };
1877 if arguments
1878 .get("cwd")
1879 .and_then(Value::as_str)
1880 .is_none_or(str::is_empty)
1881 && let Some(object) = arguments.as_object_mut()
1882 {
1883 object.insert("cwd".into(), cwd.display().to_string().into());
1884 }
1885 JobMeta {
1886 worktree: self.inner.worktree_at(&cwd),
1887 ..JobMeta::default()
1888 }
1889 }
1890
1891 fn start_as(
1893 &self,
1894 agent: &Offered,
1895 arguments: Value,
1896 context: &ToolContext,
1897 turn: Turn,
1898 meta: JobMeta,
1899 ) -> Result<Value, ToolError> {
1900 let tool: Arc<dyn Tool> = if matches!(turn, Turn::Queued(_)) {
1901 Arc::new(QueuedTool(Arc::clone(&self.inner)))
1902 } else {
1903 Arc::clone(&self.inner) as Arc<dyn Tool>
1904 };
1905 self.jobs.start(
1906 tool,
1907 AGENT_TOOL,
1908 &agent.name,
1909 arguments,
1910 context,
1911 turn,
1912 None,
1913 meta,
1914 )
1915 }
1916
1917 pub(crate) async fn owner_call(
1925 &self,
1926 mut arguments: Value,
1927 workspace: std::path::PathBuf,
1928 mode: scv_protocol::SendMode,
1929 meta: JobMeta,
1930 ) -> Result<OwnerCall, ToolError> {
1931 use scv_protocol::SendMode;
1932 let context = ToolContext::new(workspace, CancellationToken::new());
1933 self.inner.route(&arguments)?;
1934 self.inner.risk_for(&arguments, true)?;
1935 let worktree = if parse_args::<AgentArgs>(&arguments)?.worktree {
1936 self.jobs.has_room()?;
1937 self.inner
1938 .prepare_worktree(&mut arguments, &context)
1939 .await?
1940 } else {
1941 None
1942 };
1943 let (agent, routed) = self.inner.route(&arguments)?;
1944 let meta = JobMeta {
1945 origin: JobOrigin::Owner,
1946 worktree: worktree.or(meta.worktree),
1947 resumed_from: meta.resumed_from,
1948 };
1949 let Some(handle) = parse_args::<AgentArgs>(&routed)?
1950 .session
1951 .filter(|session| conversation::is_handle(session))
1952 else {
1953 return self
1954 .start_as(agent, arguments, &context, Turn::New, meta)
1955 .map(OwnerCall::Job);
1956 };
1957 let mut meta = meta;
1958 if let Some(cwd) = self.conversations.cwd(&handle) {
1959 if arguments.get("cwd").and_then(Value::as_str).is_none()
1960 && let Some(object) = arguments.as_object_mut()
1961 {
1962 object.insert("cwd".into(), cwd.display().to_string().into());
1963 }
1964 if meta.worktree.is_none() {
1965 meta.worktree = self.inner.worktree_at(&cwd);
1966 }
1967 }
1968 let place = self.jobs.lanes.join(&handle);
1969 let busy = agent.backend.busy(&routed)?;
1970 if !busy && !self.conversations.knows(&handle) {
1971 return Err(ToolError::invalid_arguments(format!(
1972 "no conversation {handle} in this session; it ended, or it was forgotten after it sat idle"
1973 )));
1974 }
1975 if place.ahead == 0 && !busy {
1976 return self
1977 .start_as(agent, arguments, &context, Turn::Next(place), meta)
1978 .map(OwnerCall::Job);
1979 }
1980 if mode != SendMode::Queue
1981 && agent
1982 .backend
1983 .steer(routed.clone(), context.clone())
1984 .await?
1985 .is_some()
1986 {
1987 return Ok(OwnerCall::Steered);
1988 }
1989 if mode == SendMode::Steer {
1990 return Err(ToolError::failed(format!(
1991 "conversation {handle} cannot take a prompt into its running turn (its agent \
1992 does not steer, or no turn is running yet); send it queued instead"
1993 )));
1994 }
1995 queue_room(agent, &handle, &place)?;
1996 self.start_as(agent, arguments, &context, Turn::Queued(place), meta)
1997 .map(OwnerCall::Job)
1998 }
1999
2000 pub(crate) fn takes(&self, agent: &str, option: &str) -> bool {
2003 self.inner.find(agent).is_some_and(|offered| match option {
2004 "model" => offered.accepts.model,
2005 "effort" => offered.accepts.effort,
2006 "session" => offered.accepts.session,
2007 _ => false,
2008 })
2009 }
2010
2011 pub(crate) fn knows(&self, handle: &str) -> bool {
2014 self.conversations.knows(handle)
2015 }
2016
2017 pub(crate) fn adopt(&self, agent: &str, cwd: &std::path::Path, vendor: &str) -> Option<String> {
2022 let offered = self.inner.find(agent)?;
2023 if !offered.accepts.session || !offered.backend.adoptable() {
2024 return None;
2025 }
2026 self.conversations.adopt(agent, cwd, vendor).ok()
2027 }
2028}
2029
2030#[derive(Deserialize)]
2031#[serde(deny_unknown_fields)]
2032struct WaitArgs {
2033 job: String,
2034 timeout_seconds: Option<u64>,
2035}
2036
2037#[derive(Deserialize)]
2038#[serde(deny_unknown_fields)]
2039struct StatusArgs {
2040 job: Option<String>,
2041}
2042
2043pub(crate) struct WaitTool {
2045 pub(crate) jobs: Arc<BackgroundJobs>,
2046 pub(crate) timeouts: Timeouts,
2047}
2048
2049#[async_trait]
2050impl Tool for WaitTool {
2051 fn spec(&self) -> ToolSpec {
2052 let mut timeout = timeout_schema(self.timeouts);
2053 timeout["description"] = format!(
2054 "Seconds to wait before returning the job still running. Defaults to {}; at most {}.",
2055 self.timeouts.default.min(self.timeouts.max).as_secs(),
2056 self.timeouts.max.as_secs()
2057 )
2058 .into();
2059 ToolSpec {
2060 name: "agent_wait".into(),
2061 description: "Wait for a background agent job (the `job` handle an agent call with \
2062 background: true returned) to finish, and return its result. Returns early with \
2063 status running when the timeout passes. Waiting holds your turn open, so the \
2064 user cannot reach you meanwhile; usually let SCV report the result instead."
2065 .into(),
2066 parameters: json!({
2067 "type":"object",
2068 "properties":{"job":{"type":"string"},"timeout_seconds":timeout},
2069 "required":["job"],
2070 "additionalProperties":false
2071 }),
2072 }
2073 }
2074
2075 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
2076 let args: WaitArgs = parse_args(arguments)?;
2077 self.timeouts.resolve(args.timeout_seconds)?;
2078 Ok(ToolRisk::ReadOnly)
2079 }
2080
2081 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
2082 let args: WaitArgs = parse_args(arguments)?;
2083 Ok(format!(
2084 "Wait for background job {}",
2085 bounded(&args.job, 64)
2086 ))
2087 }
2088
2089 async fn execute(
2090 &self,
2091 arguments: Value,
2092 context: ToolContext,
2093 ) -> Result<ToolOutput, ToolError> {
2094 let args: WaitArgs = parse_args(&arguments)?;
2095 let limit = self.timeouts.resolve(args.timeout_seconds)?;
2096 let value = self
2097 .jobs
2098 .wait(&args.job, limit, &context.cancellation, &context.call_id)
2099 .await?;
2100 Ok(ToolOutput::success(value.to_string()))
2101 }
2102}
2103
2104pub(crate) struct StatusTool {
2106 pub(crate) jobs: Arc<BackgroundJobs>,
2107}
2108
2109#[async_trait]
2110impl Tool for StatusTool {
2111 fn spec(&self) -> ToolSpec {
2112 ToolSpec {
2113 name: "agent_status".into(),
2114 description: "Show this session's background agent jobs: running ones with their \
2115 latest progress, finished ones with their result. Pass job for one job."
2116 .into(),
2117 parameters: json!({
2118 "type":"object",
2119 "properties":{"job":{"type":"string"}},
2120 "additionalProperties":false
2121 }),
2122 }
2123 }
2124
2125 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
2126 let _: StatusArgs = parse_args(arguments)?;
2127 Ok(ToolRisk::ReadOnly)
2128 }
2129
2130 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
2131 let args: StatusArgs = parse_args(arguments)?;
2132 Ok(args.job.map_or_else(
2133 || "List background jobs".into(),
2134 |job| format!("Show background job {}", bounded(&job, 64)),
2135 ))
2136 }
2137
2138 async fn execute(
2139 &self,
2140 arguments: Value,
2141 context: ToolContext,
2142 ) -> Result<ToolOutput, ToolError> {
2143 let args: StatusArgs = parse_args(&arguments)?;
2144 let value = self.jobs.describe(args.job.as_deref(), &context.call_id)?;
2145 Ok(ToolOutput::success(value.to_string()))
2146 }
2147}
2148
2149#[derive(Deserialize)]
2150#[serde(deny_unknown_fields)]
2151struct CancelArgs {
2152 job: String,
2153}
2154
2155pub(crate) struct CancelTool {
2157 pub(crate) jobs: Arc<BackgroundJobs>,
2158}
2159
2160#[async_trait]
2161impl Tool for CancelTool {
2162 fn spec(&self) -> ToolSpec {
2163 ToolSpec {
2164 name: "agent_cancel".into(),
2165 description: "Stop a running background agent job (the `job` handle an agent call \
2166 with background: true returned), for example when the user no longer wants \
2167 it. The agent and every process it started are stopped; work it already wrote \
2168 stays. Returns the job with status cancelled, and no report turn follows."
2169 .into(),
2170 parameters: json!({
2171 "type":"object",
2172 "properties":{"job":{"type":"string"}},
2173 "required":["job"],
2174 "additionalProperties":false
2175 }),
2176 }
2177 }
2178
2179 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
2180 let _: CancelArgs = parse_args(arguments)?;
2181 Ok(ToolRisk::Process)
2182 }
2183
2184 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
2185 let args: CancelArgs = parse_args(arguments)?;
2186 Ok(format!("Stop background job {}", bounded(&args.job, 64)))
2187 }
2188
2189 async fn execute(
2190 &self,
2191 arguments: Value,
2192 context: ToolContext,
2193 ) -> Result<ToolOutput, ToolError> {
2194 let args: CancelArgs = parse_args(&arguments)?;
2195 let value = self.jobs.cancel(&args.job, &context.call_id).await?;
2196 Ok(ToolOutput::success(value.to_string()))
2197 }
2198}
2199
2200#[cfg(test)]
2201mod tests;