Skip to main content

scv_tools/delegate/
background.rs

1//! Background delegations: an `agent` call with `background: true` returns a
2//! job handle at once while the agent keeps working; `agent_status` and
3//! `agent_wait` observe the job, `agent_cancel` stops it, and the session is
4//! told when it finishes so the server can report it in a turn of its own.
5//!
6//! Each call that starts a job, or shows the model a job's result, leaves a
7//! [`JobChange`] under its call ID, which the server hands to the session's
8//! clients with the call's `tool.completed` ([`BackgroundJobs::take_changes`]).
9//!
10//! A finished job stays unreported until a report turn about it succeeds. A
11//! failed one makes it due again after a delay, a bounded number of times;
12//! after that, or when another turn must not be tried, the server reports it
13//! to the client directly ([`BackgroundJobs::report_failed`]).
14//!
15//! A call that continues a conversation takes its place in that
16//! conversation's lane as it arrives. When a turn or another call is
17//! ahead, the call's busy policy decides: it steers the running turn, waits
18//! in the foreground, fails, or becomes a queued job that runs once its
19//! place comes up.
20//!
21//! A call with `review` runs as a reviewed job ([`review`](super::review)):
22//! the same job, whose one call runs the whole builder and reviewer loop and
23//! whose changes and report carry SCV's outcome.
24
25pub(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
67/// Finished jobs a session keeps for `agent_status` beyond the running ones.
68const MAX_FINISHED: usize = 16;
69/// Characters of a finished job's reply quoted in a server-started report turn.
70const REPORT_REPLY_CHARS: usize = 6000;
71/// Jobs one report turn covers; any more wait for the next.
72const REPORT_MAX_JOBS: usize = 4;
73/// How long after each failed report turn the next one is due: after the
74/// first failure, then after the second. Once they run out, the job is
75/// reported directly.
76const REPORT_RETRY_DELAYS: [Duration; 2] = [Duration::from_secs(30), Duration::from_secs(120)];
77/// How long `agent_cancel` waits for a stopped job to settle.
78const CANCEL_SETTLE: Duration = Duration::from_secs(10);
79/// Job changes kept for calls whose `tool.completed` has not taken them,
80/// such as a call whose turn was aborted; the oldest go first.
81const MAX_PENDING_CHANGES: usize = 64;
82/// Characters of a delegated prompt's first line kept as its job's task.
83const TASK_CHARS: usize = 80;
84
85/// One session's background jobs. Dropping the store (with the session's
86/// tools) cancels every job still running.
87pub struct BackgroundJobs {
88    limit: usize,
89    state: Mutex<JobsState>,
90    cancellation: CancellationToken,
91    /// Woken whenever a job finishes, so the session can report it.
92    finished: Option<mpsc::UnboundedSender<()>>,
93    /// Decides a running job's nested approval requests, since no turn is
94    /// left to carry them to a person. Without it they are denied.
95    approvals: Option<Arc<dyn ApprovalGate>>,
96    /// The `agent` calls continuing each conversation, in arrival order.
97    lanes: Arc<Lanes>,
98    /// Where the session's jobs are recorded durably, when it keeps them.
99    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 by the ID of the call that made them, oldest first.
116    changes: Vec<(String, JobChange)>,
117    /// The final outcomes of cancelled reviewed jobs the session is owed.
118    owed: owed::Owed,
119}
120
121impl JobsState {
122    /// Bring the outcome in the change the call `call_id` made for
123    /// `outcome`'s job up to date, if no client has taken it yet.
124    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    /// Keep `change` for the call `call_id`, which a client learns of with
136    /// that call's `tool.completed`. A call without an ID has no event.
137    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    /// The tool that started it, `agent`.
152    tool: String,
153    /// The agent that runs it, such as `codex`.
154    agent: String,
155    /// The first line of the delegated prompt, shortened.
156    task: String,
157    started: Instant,
158    progress: ProgressSink,
159    last_progress: Option<String>,
160    /// Stops this job alone.
161    cancel: CancellationToken,
162    /// `agent_cancel` stopped it.
163    cancelled: bool,
164    /// A busy conversation's queued prompt, which `agent.max_queued_turns`
165    /// bounds instead of `agent.max_background`.
166    queued: bool,
167    outcome: Option<Outcome>,
168    /// The model has seen the result (through `agent_wait`, `agent_status`,
169    /// or a report turn that completed), the user stopped its report turn,
170    /// the server reported it directly, or the model asked for the stop with
171    /// `agent_cancel`, so it needs no report turn.
172    reported: bool,
173    /// A report turn about it runs.
174    reporting: bool,
175    /// Report turns about it that failed.
176    report_attempts: u32,
177    /// After a failed report turn: when the next one is due.
178    report_due: Option<tokio::time::Instant>,
179    done: watch::Receiver<bool>,
180    /// A reviewed job's record, whose outcome its changes and report state.
181    review: Option<Arc<Record>>,
182    /// `agent_cancel` waits for it, and describes it after: never pruned
183    /// meanwhile.
184    cancelling: bool,
185    /// Who started it. The owner's own jobs are never reported to the
186    /// model: the owner watches them in the workbench.
187    origin: JobOrigin,
188    /// A queued prompt whose turn has not come yet.
189    queued_waiting: bool,
190    /// The conversation whose lane a queued prompt waits in.
191    lane: Option<String>,
192    /// The owner stopped it from the workbench, so the model was not told.
193    owner_cancelled: bool,
194    /// Its durable record, when the session keeps a ledger, and that
195    /// record's revision: each change bumps it, and it is written after the
196    /// job table's lock is let go ([`BackgroundJobs::persist`]).
197    record: Option<JobRecord>,
198    record_revision: u64,
199}
200
201/// A revision of a job's record to write once the job table is let go.
202type RecordWrite = (JobRecord, u64);
203
204impl Job {
205    /// The record as it is now, under a new revision, to write.
206    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
213/// `agent_cancel` waiting for a job to stop. Settled after the wait, or,
214/// if the call is dropped first, as when its turn is aborted, its change
215/// withdrawn and any outcome it owed left to an update.
216struct Waiting<'a> {
217    jobs: &'a BackgroundJobs,
218    job: String,
219    call: String,
220    /// The job is reviewed: the session is owed its final outcome.
221    owed: bool,
222    settled: bool,
223}
224
225impl Waiting<'_> {
226    /// The wait is over: the change the call made says the final outcome
227    /// if the job stopped, else the outcome as decided, its journal still
228    /// open; [`BackgroundJobs::take_changes`] says the final one if the job
229    /// stops before the change is sent.
230    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        // Its call is gone, so its change is never sent.
264        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/// What a failed report turn leaves its jobs to.
282#[derive(Debug, PartialEq, Eq)]
283pub enum ReportFailure {
284    /// Another report turn about them is due after this long.
285    Retry(Duration),
286    /// No more report turns: the server reports them directly. `attempts`
287    /// counts the report turns that failed.
288    GiveUp {
289        attempts: u32,
290        reports: Vec<JobReport>,
291    },
292    /// The model saw each of them through a call during the turn.
293    Settled,
294}
295
296impl Drop for BackgroundJobs {
297    fn drop(&mut self) {
298        self.cancellation.cancel();
299        // Jobs still going stop with their session: say so durably, since
300        // their own end is never recorded once the store is gone.
301        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    /// At most `limit` jobs run at once. `finished` is woken as jobs finish.
318    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    /// Record the session's jobs durably in `ledger`. Job IDs continue
331    /// after the ones it already holds.
332    #[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    /// Number new jobs after the `job-<n>` IDs `jobs` names too: the
340    /// session's runs name jobs whose records may be gone.
341    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    /// Decide running jobs' nested approval requests with `gate`, which must
354    /// never grant more than the session's foreground would.
355    #[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    /// Start `tool` with `arguments` in the background for the call in
370    /// `context` (whose workspace and call ID it takes; the job has its own
371    /// cancellation), on `agent`, and return its job's
372    /// `{"job","agent","status":"running","background":true}` description.
373    /// A reviewed job's `review` records its start in its journal first, and
374    /// the call fails if it cannot. That write happens outside the job
375    /// table's lock, so a slow disk never holds up the session's other job
376    /// calls: the job's number is taken first, and a failed write leaves it
377    /// unused.
378    #[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            // Checked again: another call may have started a job meanwhile.
425            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            // No turn is left to ask a person, so nested approval requests
500            // get the session's unattended answer, or are denied.
501            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                        // A reviewed job ends its review even when its loop
525                        // never ran, so its result says how it ended.
526                        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            // Free the conversation before the result is visible, so a call
536            // continuing it after `agent_wait` finds it idle.
537            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    /// A queued job's turn has come: it runs now.
565    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    /// The conversation handles the session's job records name.
584    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    /// Whether the session still owes its client `job`'s result: dropping
592    /// its record now would lose it.
593    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    /// Mark `job`'s result reported in its durable record; the change to
601    /// write, when there is one.
602    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    /// Write the record changes `writes`, outside the job table's lock, so
610    /// a slow disk never holds up the session's job calls. The write syncs
611    /// to disk, so on a runtime's worker thread the runtime is told it
612    /// blocks, and moves its other tasks elsewhere meanwhile.
613    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    /// The session's jobs as they stand, for the workbench.
633    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    /// Reviewed job `job`'s outcome as it stands, while this session still
654    /// keeps the job; `None` for any other job.
655    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    /// Stop `job` for the owner, from the workbench: as `agent_cancel` does,
664    /// but the model did not ask, so its report turn tells it the owner
665    /// stopped the job. A reviewed job's report carries its outcome as the
666    /// cancel left it.
667    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    /// Refuse a new running job beyond `agent.max_background`, before
684    /// anything is prepared for it.
685    fn has_room(&self) -> Result<(), ToolError> {
686        let state = self.state();
687        self.room(&state, false)
688    }
689
690    /// Refuse a job beyond `agent.max_background`; a queued prompt counts
691    /// toward `agent.max_queued_turns` instead.
692    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    /// Stop `job` for the call `call_id` and wait briefly for it to settle,
709    /// then describe it. A stopped job needs no report turn: the model asked
710    /// for the stop.
711    ///
712    /// A reviewed job's decision is fixed at once, but its journal goes on
713    /// until it really stops, so the session is owed its final outcome
714    /// ([`owed`]): the call's change says it if the job stops before the
715    /// change is sent, and otherwise says the journal is still open, and a
716    /// `background.updated` follows ([`Self::take_updates`]). The wait
717    /// reads only the job's live state, never its journal.
718    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                // The model has its result now, as `agent_status` gives it.
737                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    /// The final outcomes of cancelled reviewed jobs the session is owed
780    /// and has not been sent, for the server's `background.updated`. Each
781    /// is being sent until [`Self::updated`] says how that went.
782    pub fn take_updates(&self) -> Vec<JobOutcome> {
783        self.state().owed.take_updates()
784    }
785
786    /// The update naming `jobs` reached the client, when `delivered`, which
787    /// ends what was owed; or it could not be sent, which leaves them due.
788    pub fn updated(&self, jobs: &[String], delivered: bool) {
789        self.state().owed.updated(jobs, delivered);
790    }
791
792    /// The `tool.completed` of the call `call_id`, carrying the changes
793    /// [`Self::take_changes`] gave, reached the client, when `delivered`, or
794    /// could not be sent, as when its turn was cancelled first. A change
795    /// that did not deliver a reviewed job's final outcome leaves it to a
796    /// `background.updated`.
797    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    /// A turn ended: no change of its calls can be sent any more, so what
805    /// such a change owed goes to a `background.updated`.
806    pub fn turn_ended(&self) {
807        let due = self.state().owed.turn_ended();
808        if due {
809            self.wake();
810        }
811    }
812
813    /// For tests of how a client of this store delivers reviewed jobs'
814    /// outcomes, such as the server's, with the `test-support` feature: owe
815    /// the session the final outcome of `change.job`, through `change`,
816    /// recorded for the call `call_id` once `agent_cancel` waited, which says
817    /// the journal is still open; `last` is the final outcome if the job has
818    /// stopped.
819    #[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    /// For the same tests: the job of `outcome` stops with it.
827    #[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    /// For tests of what waits for a session's jobs, with the
836    /// `test-support` feature: start a job on `agent` that runs until the
837    /// returned sender is used or dropped.
838    #[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    /// Wake the session, as when a job finishes, for what is now due.
887    fn wake(&self) {
888        if let Some(finished) = &self.finished {
889            let _ = finished.send(());
890        }
891    }
892
893    /// Wait up to `limit` for `job` and describe it for the call `call_id`;
894    /// a finished job is then marked seen.
895    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    /// Describe one job, or every job this session remembers, for the call
917    /// `call_id`; finished jobs described are marked seen.
918    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    /// The jobs the call `call_id` started or showed the model the result of,
958    /// for its `tool.completed`, which then reports how its sending went
959    /// ([`Self::published`]). A cancelled reviewed job's change says its
960    /// final outcome if the job has stopped by now, superseding the
961    /// snapshot taken when the cancel's wait ended.
962    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    /// Finished jobs the model has not seen yet whose report is due, at most
980    /// a few, for a report turn. Each stays unreported, and is not taken
981    /// again, until the turn ends: [`report_settled`](Self::report_settled)
982    /// or [`report_failed`](Self::report_failed).
983    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    /// The report turn about `jobs` ended without failing: it completed, so
999    /// the model has seen them, or the user cancelled it. Either settles them.
1000    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    /// The report turn about `jobs` failed, so the model has not seen them
1015    /// (a failed turn leaves no history). With `retry`, another turn is due
1016    /// after a delay, up to three turns in all; otherwise, or once they are
1017    /// used up, the jobs are settled and returned for the server to report
1018    /// directly. A turn that reported several jobs decides for all of them,
1019    /// by the one that failed most.
1020    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    /// [`Self::report_failed`] under the job table's lock, gathering the
1028    /// record changes to write in `writes`.
1029    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        // One the turn's model looked up through a call is settled already.
1046        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    /// Make every report waiting out a failed turn due now, as once a turn
1073    /// succeeds and the model is evidently reachable again. Whether any was
1074    /// waiting.
1075    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    /// When the earliest report waiting out a failed turn is due; it may
1085    /// already have passed.
1086    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    /// Jobs still running or whose result is not reported yet, including
1096    /// those a report turn is reporting now, and cancelled reviewed jobs
1097    /// whose final outcome the session is still owed, until it was sent;
1098    /// each job once.
1099    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    /// Of the [`pending`](Self::pending) jobs, the queued prompts that wait
1112    /// only for the owner's takeover of their conversation to end. A planned
1113    /// restart does not wait for them: the restart ends the takeover, and
1114    /// they are kept for the next session like any queued prompt.
1115    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    /// Whether any job is still running.
1129    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/// Who started a job, the worktree it runs in, and the interrupted job it
1139/// resumes: what its record holds from the start.
1140#[derive(Debug, Clone, Default)]
1141pub(crate) struct JobMeta {
1142    pub(crate) origin: JobOrigin,
1143    pub(crate) worktree: Option<scv_protocol::WorktreeRef>,
1144    /// `<session>/<job>` of the interrupted job it runs again.
1145    pub(crate) resumed_from: Option<String>,
1146}
1147
1148/// What an owner's prompt to the workbench became.
1149#[derive(Debug, Clone, PartialEq)]
1150pub(crate) enum OwnerCall {
1151    /// A background job: its start, as an `agent` call returns it.
1152    Job(Value),
1153    /// The prompt went into the conversation's running turn.
1154    Steered,
1155}
1156
1157/// A job as the workbench sees it while its session lives.
1158#[derive(Debug, Clone)]
1159pub(crate) struct LiveJob {
1160    pub(crate) id: String,
1161    /// A queued prompt whose turn has not come yet.
1162    pub(crate) waiting: bool,
1163    pub(crate) running: bool,
1164    pub(crate) progress: Option<String>,
1165    pub(crate) elapsed: Duration,
1166}
1167
1168/// The workbench state of a job that ended with `status`.
1169pub(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
1180/// How a job's call takes its conversation's turn.
1181pub(in crate::delegate) enum Turn {
1182    /// A new conversation, which nothing else can reach yet.
1183    New,
1184    /// The conversation's next turn, which nothing was ahead of.
1185    Next(Place),
1186    /// A prompt queued behind the turns ahead of it.
1187    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    // The session ended first: its jobs were cancelled with it.
1201    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            // Stopped on the model's request: the model already knows. The
1212            // owner's own jobs are the owner's to watch, never the model's.
1213            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        // What a cancel owed of it is now final.
1229        if let Some(last) = last {
1230            state.owed.stopped(id, &last);
1231        }
1232        // Keep every running job and the newest finished ones.
1233        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    // Recorded before anyone hears the job finished.
1249    jobs.persist(write);
1250    if let Some(finished) = &jobs.finished {
1251        let _ = finished.send(());
1252    }
1253}
1254
1255impl Job {
1256    /// Finished, unseen by the model, and not in a report turn.
1257    fn awaits_report(&self) -> bool {
1258        self.outcome.is_some() && !self.reported && !self.reporting
1259    }
1260
1261    /// The finished job's result, for a report.
1262    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            // Stopped by the owner: the model asked for no stop of its own.
1270            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    /// A reviewed job's outcome as it stands. A cancelled job's is what it
1288    /// was when the cancel arrived.
1289    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    /// This job with `status`, as its clients learn of it: a reviewed job's
1303    /// settling change carries its outcome.
1304    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    /// The job as the model reads it, and its change when this is the first
1321    /// time the model sees its result.
1322    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
1370/// The agent tool's structured result, or its text when it was not JSON.
1371fn 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
1378/// How a finished job ended: its agent's reported status, else whether its
1379/// call failed.
1380fn 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
1388/// The first non-empty line of `prompt`, at most [`TASK_CHARS`] characters,
1389/// which names a job to people.
1390fn 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
1414/// What the model is told when a report includes reviewed jobs, whose
1415/// outcome SCV states itself.
1416const 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
1430/// The server-started turn that reports finished jobs to the model.
1431pub 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
1442/// What the model is told when a report includes a job someone else
1443/// stopped: a job the model stops itself is never reported.
1444fn 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
1456/// What the model is told of jobs the server reported directly, because
1457/// their report turns failed with `error`: the user has their results.
1458pub 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
1469/// The `agent` tool, able to run a call in the background as well, and to
1470/// run one as a reviewed job.
1471pub(crate) struct BackgroundCapable {
1472    pub(crate) inner: Arc<AgentTool>,
1473    pub(crate) jobs: Arc<BackgroundJobs>,
1474    /// The session's conversations, which a reviewed job pins and releases.
1475    pub(crate) conversations: Arc<ConversationStore>,
1476    /// Where reviewed jobs keep their journals; without it, `review` is not
1477    /// offered.
1478    pub(crate) reviews: Option<PathBuf>,
1479    /// The SCV session, which a review's journal names.
1480    pub(crate) session: Option<String>,
1481}
1482
1483/// The `agent` tool for a queued prompt, whose turn has come by the time it
1484/// runs, so no busy policy applies to it again.
1485struct 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
1507/// An agent call's arguments with `background` and `review` split off.
1508struct Split {
1509    arguments: Value,
1510    /// `background` as the call gave it.
1511    background: Option<bool>,
1512    /// The `review` object, when the call gave one.
1513    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        // Validate before returning a job handle, so a bad call fails now,
1629        // and as the call was made: a foreground call queued behind a busy
1630        // conversation runs as a job but may not set `no_timeout`.
1631        self.inner.risk_for(&arguments, background)?;
1632        // A background call's worktree is created before its job starts,
1633        // so the job's record and start name it. A foreground call's is
1634        // the agent tool's.
1635        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        // Taken before anything runs or waits, so calls keep their order.
1666        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                // Held until the turn ends, so a later call finds it busy.
1672                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            // A background call cannot hold its caller, so it queues instead.
1688            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
1703/// The `session busy` error for a call to conversation `handle` that found
1704/// a turn running, or calls waiting, at `place`.
1705fn busy(handle: &str, place: &Place) -> ToolError {
1706    // Calls ahead of this one other than the running turn.
1707    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
1718/// Refuse a prompt queued at `place` beyond `agent.max_queued_turns`.
1719fn 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    /// `inner` with `jobs` and a store of its own, without reviews: for
1734    /// tests of plain background calls.
1735    #[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    /// Whether calls may set `review`: background jobs exist (this tool),
1753    /// the session keeps journals, and an offered agent can continue a
1754    /// conversation.
1755    fn offers_review(&self) -> bool {
1756        self.reviews.is_some() && !self.inner.continuing().is_empty()
1757    }
1758
1759    /// Check a reviewed call, before approval and again before it starts.
1760    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    /// Start a reviewed call as a background job. A busy builder
1783    /// conversation queues it, or fails it with `on_busy: fail`; it never
1784    /// waits, since it is a background job, and never steers, since its
1785    /// prompt opens a review of its own.
1786    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        // The builder and every reviewer work in the same worktree.
1796        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    /// Start the call on `agent` as a background job of this session.
1882    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    /// A queued follow-up has no run of its own. Remember the conversation's
1895    /// directory and SCV worktree on the job, so a later resume still has
1896    /// them after the job that run served was dismissed and the worktree
1897    /// removed.
1898    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    /// Start the call on `agent` as a background job `meta` describes.
1927    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    /// Run the owner's call from the workbench as a background job of this
1953    /// session: no approval request, since it is the owner's own action,
1954    /// and no report turn, since the owner watches it. A call continuing a
1955    /// conversation that runs a turn steers it when `mode` allows and the
1956    /// agent can take the prompt, and otherwise queues behind it. `meta`'s
1957    /// worktree is recorded when the call makes none of its own, and its
1958    /// `resumed_from` as given; it is always the owner's.
1959    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    /// Whether `agent` is offered and takes `option` (`model`, `effort`,
2036    /// or `session`).
2037    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    /// Whether the session has conversation `handle`, so the owner can send
2047    /// it a prompt.
2048    pub(crate) fn knows(&self, handle: &str) -> bool {
2049        self.conversations.knows(handle)
2050    }
2051
2052    /// Take in conversation `vendor` that `agent` saved, so a call can
2053    /// continue it in `cwd`, when that agent can continue a conversation
2054    /// from its own session ID alone. Returns the handle, which is the one
2055    /// this session already holds for that agent, session, and directory.
2056    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    /// Take conversation `handle` over in its agent's own terminal for the
2065    /// owner, sized `size`, as SCV session `session`: refused, with why,
2066    /// when the agent's terminal may not be offered, or the conversation
2067    /// runs a turn, has turns waiting, or has no agent session to open.
2068    /// Calls that arrive meanwhile queue behind the takeover in its lane.
2069    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
2110/// `agent_wait`: block until a background job finishes, or the timeout.
2111pub(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
2171/// `agent_status`: describe one background job or all of them.
2172pub(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
2222/// `agent_cancel`: stop one running background job.
2223pub(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;