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