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