Skip to main content

ostraka_runtime/
orchestrator.rs

1//! One task, start to finish.
2//!
3//! Isolate, execute, gate, review, record. The orchestrator drives every step
4//! and can approve none of them: the only thing that ends a run favourably is a
5//! [`crate::gate::MergeToken`], which this module cannot construct.
6
7use crate::author;
8use crate::gate::{self, MergeToken, Refusal};
9use crate::progress::{Phase, Watcher};
10use crate::record::RunLog;
11use crate::review;
12use crate::route::Routing;
13use crate::worktree;
14use crate::{Error, Result};
15use ostraka_adapter::{AdapterOutcome, VendorAdapter};
16use ostraka_core::clock::now_rfc3339;
17use ostraka_core::config::Config;
18use ostraka_core::gate::{Approval, Verdict};
19use ostraka_core::identity::ActorId;
20use ostraka_core::record::{Event, Outcome, RunRecord};
21use ostraka_core::task::TaskSpec;
22use std::path::Path;
23
24/// What a finished run produced.
25pub struct RunReport {
26    pub record: RunRecord,
27    /// Present only when the gate minted one. Its absence is the refusal.
28    pub token: Option<MergeToken>,
29    pub refusal: Option<Refusal>,
30    pub diff: String,
31}
32
33impl RunReport {
34    pub fn approved(&self) -> bool {
35        self.token.is_some()
36    }
37}
38
39/// Where the parts of a run live.
40///
41/// Three directories that used to be one: a run is made *in* a repository,
42/// *beside* a set of worktrees, and *recorded* somewhere that may be neither.
43/// Deriving the second two from the first assumed a workspace holding exactly
44/// one repository, which is the assumption this replaces — and passing them
45/// explicitly means the layout is decided by whoever knows it rather than
46/// rebuilt from a convention in here.
47pub struct Places<'a> {
48    /// The repository the change is made in.
49    pub repo: &'a Path,
50    /// Where worktrees are created.
51    pub worktrees: &'a Path,
52    /// Where run records are written.
53    pub records: &'a Path,
54    /// The name this repository is known by, for the record.
55    pub name: &'a str,
56    /// The workspace's notes, linked into the worktree so what an agent works
57    /// out survives the run. `None` where there are none.
58    pub notes: Option<&'a Path>,
59    /// The workspace's skills, linked in the same way and for the mirror
60    /// reason: what people wrote down for a run to follow. `None` where there
61    /// are none.
62    pub skills: Option<&'a Path>,
63}
64
65/// Runs one task through the whole pipeline.
66///
67/// The worktree is left in place on completion so the diff can be inspected;
68/// removing it is the caller's decision, not this function's.
69///
70/// `watcher` is told what is happening while it happens, for a caller that has
71/// a screen to keep up to date. It cannot change any of it: see
72/// [`crate::progress`]. `None` behaves exactly as this function always has.
73pub fn run_task(
74    places: &Places<'_>,
75    config: &Config,
76    routing: &Routing,
77    task: &TaskSpec,
78    reviewer_identity: &ActorId,
79    watcher: Option<Box<dyn Watcher>>,
80) -> Result<RunReport> {
81    run_task_until(
82        places,
83        config,
84        routing,
85        task,
86        reviewer_identity,
87        watcher,
88        &ostraka_adapter::interrupt::Stop::new(),
89    )
90}
91
92/// [`run_task`], for a run that can be stopped without stopping any other.
93///
94/// `stop` is the same one `routing` was built with by
95/// [`crate::route::select_until`]: the adapters answer to it through the
96/// routing, and the gate's checks answer to it here. Ctrl-C still stops every
97/// run, because a `Stop` also answers to the process-wide request.
98pub fn run_task_until(
99    places: &Places<'_>,
100    config: &Config,
101    routing: &Routing,
102    task: &TaskSpec,
103    reviewer_identity: &ActorId,
104    watcher: Option<Box<dyn Watcher>>,
105    stop: &ostraka_adapter::interrupt::Stop,
106) -> Result<RunReport> {
107    let repo = places.repo;
108    let run_id = format!("{}-{}", task.id, now_rfc3339().replace([':', '-'], ""));
109    let mut log = RunLog::create(places.records, &run_id)?.watched_by(watcher);
110    log.enter(Phase::Isolating);
111
112    let mut record = RunRecord {
113        run_id: run_id.clone(),
114        task_id: task.id.clone(),
115        prompt: task.prompt.clone(),
116        author: task.author.clone(),
117        adapter: routing.author.id().to_string(),
118        repository: places.name.to_string(),
119        started_at: now_rfc3339(),
120        finished_at: None,
121        checks: Vec::new(),
122        approval: None,
123        usage: Vec::new(),
124        outcome: None,
125    };
126
127    // 1. Isolate. Work is a diff on disk before it is anything else.
128    let wt = worktree::create(repo, places.worktrees, &run_id, &task.base_ref)?;
129
130    // 2. Make the checkout usable. A worktree is a fresh checkout, so whatever
131    //    git ignores is missing from it — and the agent needs the project's
132    //    tools as much as the gate does.
133    log.enter(Phase::Preparing);
134    match worktree::prepare(
135        repo,
136        wt.path(),
137        &config.worktree,
138        places.notes,
139        places.skills,
140        config.gate.timeout_secs.map(std::time::Duration::from_secs),
141    ) {
142        Ok(steps) => {
143            for step in steps {
144                log.append(&Event::Message {
145                    text: format!("prepared: {step}"),
146                    raw: None,
147                })?;
148            }
149        }
150        Err(problem) => {
151            return finish(
152                log,
153                record,
154                Outcome::Failed,
155                None,
156                Some(Refusal::SetupFailed {
157                    step: problem.step,
158                    reason: problem.reason,
159                }),
160                String::new(),
161            );
162        }
163    }
164
165    // 3. Execute, streaming events into the log as they arrive so an
166    //    interrupted run still leaves an account of how far it got.
167    log.enter(Phase::Authoring);
168    // A separate spec, the way review builds one. `task.prompt` is the
169    // operator's sentence and it is read again further down — by the record and
170    // by the commit message — so composing in place would put this preamble in
171    // both, where it is neither what was asked nor part of the audit trail.
172    // Read off the worktree rather than off the configuration. Naming notes in
173    // `[worktree]` is an intention; a repository that tracks its own `notes/`
174    // keeps it, and telling an agent otherwise would point it at the diff.
175    let authoring = TaskSpec {
176        prompt: author::author_prompt(
177            &task.prompt,
178            worktree::linked(wt.path(), "notes"),
179            worktree::linked(wt.path(), "skills"),
180        ),
181        ..task.clone()
182    };
183    let author = drive(routing.author.as_ref(), &authoring, wt.path(), &mut log)?;
184    record.usage.extend(author.usage.clone());
185
186    // 3. Read what was actually touched, from git rather than from the agent.
187    let touched = worktree::touched_paths(wt.path())?;
188    log.append(&Event::Finished {
189        exit_code: author.exit_code,
190        files_touched: touched.clone(),
191    })?;
192
193    // Nothing was written, so there is nothing a reviewer can rule on: it would
194    // be handed an empty diff and asked what it thinks of it, which spends a
195    // second vendor to produce a confused answer and then reports that answer
196    // as the reason. Why it is empty is the reason, and it is known here.
197    // A killed author is refused whether or not it left something behind: what
198    // is on disk is half of whatever it was doing, and half a change is not a
199    // change anybody should be asked to review.
200    if author.interrupted {
201        return finish(
202            log,
203            record,
204            Outcome::Rejected,
205            None,
206            Some(Refusal::Interrupted),
207            String::new(),
208        );
209    }
210
211    if author.timed_out {
212        return finish(
213            log,
214            record,
215            Outcome::Rejected,
216            None,
217            Some(Refusal::TimedOut {
218                after_secs: config.policy.timeout_secs.unwrap_or_default(),
219            }),
220            String::new(),
221        );
222    }
223
224    // An author that did not exit cleanly did not finish, and what is on disk
225    // is half of whatever it was doing. Half a change is not a change anybody
226    // should be asked to review — the same reasoning that refuses a killed
227    // author, and the same situation: a context window running out is a stop
228    // like any other, and only who stopped it differs.
229    //
230    // Being this strict costs something, and it is worth naming: a vendor that
231    // exits non-zero for a harmless reason now has finished work refused
232    // rather than reviewed. That is the safe direction and it is recoverable —
233    // a refused run keeps its worktree, the record carries the vendor's own
234    // words, and running it again is one command. The unsafe direction is not:
235    // a half-written change that happened to compile was gated, reviewed and
236    // approved, which is what this used to do.
237    if author.exit_code != Some(0) {
238        let refusal = Refusal::AuthorFailed {
239            code: author
240                .exit_code
241                .map(|c| c.to_string())
242                .unwrap_or_else(|| "no exit code".to_string()),
243            diagnostics: author.diagnostics.clone(),
244        };
245        return finish(
246            log,
247            record,
248            Outcome::Rejected,
249            None,
250            Some(refusal),
251            String::new(),
252        );
253    }
254
255    if touched.is_empty() {
256        return finish(
257            log,
258            record,
259            Outcome::Rejected,
260            None,
261            Some(Refusal::NoChange),
262            String::new(),
263        );
264    }
265
266    if !config.policy.permits(&touched) {
267        return finish(
268            log,
269            record,
270            Outcome::Rejected,
271            None,
272            Some(Refusal::PolicyViolation {
273                reason: format!("policy forbids writing outside declared paths: {touched:?}"),
274            }),
275            String::new(),
276        );
277    }
278
279    // 4. Gate. These commands actually run; their output is captured.
280    log.enter(Phase::Gating);
281    let passed = match gate::run_checks_until(&config.gate, wt.path(), stop, &mut |record| {
282        log_checked(&mut log, record)
283    }) {
284        Ok(p) => p,
285        Err(refusal) => {
286            // Keep the evidence. A failed run is the one someone will need to
287            // read afterwards, so the records travel from the refusal into the
288            // record before anything returns.
289            if let Refusal::ChecksFailed { records, .. } = &refusal {
290                record.checks.clone_from(records);
291            }
292            return finish(
293                log,
294                record,
295                Outcome::Rejected,
296                None,
297                Some(refusal),
298                String::new(),
299            );
300        }
301    };
302    record.checks = passed.records().to_vec();
303
304    // 5. Freeze the change. What the gate left behind is what gets reviewed,
305    // and what gets reviewed is the only thing an approval may commit.
306    let diff = worktree::diff(wt.path())?;
307    let reviewed = worktree::tree(wt.path())?;
308
309    // Policy again, on the change as it now stands. The check above saw what
310    // the author touched before any check ran; a check that writes a file
311    // outside the declared paths put it in this diff and in the commit without
312    // policy ever looking.
313    let staged = worktree::staged_paths(wt.path())?;
314    if !config.policy.permits(&staged) {
315        return finish(
316            log,
317            record,
318            Outcome::Rejected,
319            None,
320            Some(Refusal::PolicyViolation {
321                reason: format!(
322                    "the change as it stood after the gate writes outside declared paths: \
323                     {staged:?}"
324                ),
325            }),
326            diff,
327        );
328    }
329
330    // 6. Review, by an adapter that is not the one that wrote the change.
331    log.enter(Phase::Reviewing);
332    let (verdict, reviewer_usage) = collect_verdict(
333        routing.reviewer.as_ref(),
334        task,
335        &diff,
336        &record.checks,
337        wt.path(),
338        &mut log,
339    )?;
340    record.usage.extend(reviewer_usage);
341    let approval = Approval {
342        reviewer: reviewer_identity.clone(),
343        verdict: verdict.clone(),
344    };
345    record.approval = Some(approval.clone());
346
347    // The reviewer ran inside the worktree it was judging, and two shipped
348    // profiles have no read-only posture at all. So the tree is compared before
349    // anything is minted: a worktree that no longer matches what was reviewed
350    // has not been reviewed, whatever the verdict says — and committing it
351    // would put a reviewer's own ungated, unreviewed edit under an approval.
352    // Refused rather than repaired: resetting to the reviewed tree would hide
353    // that a reviewer wrote to a change it was only meant to read, and the
354    // worktree a refused run keeps is the evidence of exactly that.
355    let after = worktree::restage(wt.path())?;
356    if after != reviewed {
357        return finish(
358            log,
359            record,
360            Outcome::Rejected,
361            None,
362            Some(Refusal::PolicyViolation {
363                reason: format!(
364                    "the worktree changed while it was being reviewed (tree {reviewed} was \
365                     reviewed, {after} is what is there now); a reviewer must not write to the \
366                     change it judges, so nothing was committed"
367                ),
368            }),
369            diff,
370        );
371    }
372
373    // 7. The gate decides. Nothing above this line can mint a token.
374    match gate::evaluate(
375        passed,
376        &task.author,
377        &approval,
378        config.gate.review.must_differ_from_author,
379    ) {
380        Ok(token) => {
381            // The trailers are the audit trail in the place it survives longest:
382            // a commit outlives the run directory it came from.
383            let message = format!(
384                "{}\n\nRun: {run_id}\nAuthored-by: {} ({})\nReviewed-by: {} ({})",
385                task.prompt,
386                task.author,
387                routing.author.id(),
388                reviewer_identity,
389                routing.reviewer.id(),
390            );
391            worktree::commit(wt.path(), &message, &task.author)?;
392            // The commit is on the run's branch now, and everything downstream
393            // reads it from there: the diff pane, `replay`, and promotion. The
394            // checkout is redundant, and a directory per run is how a busy
395            // repository fills a disk with copies of itself.
396            //
397            // Only on success. A refused run's worktree is the evidence someone
398            // needs to see what went wrong, and deleting it would take that
399            // away at exactly the moment it matters.
400            if let Err(e) = worktree::release(repo, &wt) {
401                log.append(&Event::Error {
402                    message: format!("the worktree could not be removed: {e}"),
403                    raw: None,
404                })?;
405            }
406            finish(log, record, Outcome::Approved, Some(token), None, diff)
407        }
408        Err(refusal) => finish(log, record, Outcome::Rejected, None, Some(refusal), diff),
409    }
410}
411
412/// Reports one finished check.
413///
414/// A free function rather than a closure body so the borrow of the log inside
415/// `run_checks` stays a single obvious line.
416fn log_checked(log: &mut RunLog, record: &ostraka_core::gate::CheckRecord) {
417    log.checked(record);
418}
419
420/// Runs an adapter to completion, logging every event.
421fn drive(
422    adapter: &dyn VendorAdapter,
423    task: &TaskSpec,
424    worktree: &Path,
425    log: &mut RunLog,
426) -> Result<AdapterOutcome> {
427    let mut session = adapter.launch(task, worktree)?;
428    while let Some(event) = session.next_event() {
429        log.append(&event)?;
430    }
431    let outcome = session.finish();
432    // Recorded whether or not anything else reads it. A run that ended badly
433    // and whose cause was discarded looks like an agent that simply did
434    // nothing, and the transcript is where somebody goes to find out which.
435    if let Some(diagnostics) = &outcome.diagnostics {
436        log.append(&Event::Error {
437            message: format!("author exited abnormally: {diagnostics}"),
438            raw: None,
439        })?;
440    }
441    Ok(outcome)
442}
443
444/// Runs the reviewer and reads its answer.
445///
446/// Fail-safe throughout: a reviewer that cannot be launched, or that says
447/// nothing usable, has not approved anything.
448type Reviewed = (Verdict, Option<ostraka_core::record::TokenUsage>);
449
450fn collect_verdict(
451    reviewer: &dyn VendorAdapter,
452    task: &TaskSpec,
453    diff: &str,
454    checks: &[ostraka_core::gate::CheckRecord],
455    worktree: &Path,
456    log: &mut RunLog,
457) -> Result<Reviewed> {
458    // Derived here, after the author has finished, and handed only to the
459    // reviewer: it is what lets the verdict be read from anywhere in the answer
460    // without an author being able to plant one in the diff.
461    let marker = review::verdict_marker(&task.id);
462    let review_task = TaskSpec {
463        id: format!("{}-review", task.id),
464        prompt: review::review_prompt(&task.prompt, diff, checks, &marker),
465        adapter: reviewer.id().to_string(),
466        author: task.author.clone(),
467        base_ref: task.base_ref.clone(),
468        model: None,
469    };
470
471    let mut session = match reviewer.launch(&review_task, worktree) {
472        Ok(s) => s,
473        Err(e) => {
474            return Ok((
475                Verdict::Reject {
476                    reason: format!("reviewer could not be launched: {e}"),
477                },
478                None,
479            ));
480        }
481    };
482
483    let mut spoken = String::new();
484    while let Some(event) = session.next_event() {
485        if let Event::Message { text, .. } = &event {
486            spoken.push_str(text);
487            spoken.push('\n');
488        }
489        log.append(&event)?;
490    }
491    let outcome = session.finish();
492    if outcome.exit_code != Some(0) {
493        let code = outcome
494            .exit_code
495            .map(|c| c.to_string())
496            .unwrap_or_else(|| "no exit code".to_string());
497        // Say why. A reviewer that failed because a credential expired and one
498        // that failed because it disagreed are the same exit code, and only one
499        // of them is about the change.
500        let reason = match &outcome.diagnostics {
501            Some(d) => format!("reviewer could not run (exit {code}): {d}"),
502            None => format!("reviewer could not run (exit {code}), and said nothing"),
503        };
504        log.append(&Event::Error {
505            message: reason.clone(),
506            raw: None,
507        })?;
508        return Ok((Verdict::Reject { reason }, outcome.usage));
509    }
510
511    Ok((review::parse_verdict(&spoken, &marker), outcome.usage))
512}
513
514fn finish(
515    log: RunLog,
516    mut record: RunRecord,
517    outcome: Outcome,
518    token: Option<MergeToken>,
519    refusal: Option<Refusal>,
520    diff: String,
521) -> Result<RunReport> {
522    record.finished_at = Some(now_rfc3339());
523    record.outcome = Some(outcome);
524    log.write_record(&record)?;
525    Ok(RunReport {
526        record,
527        token,
528        refusal,
529        diff,
530    })
531}
532
533/// Reads a previous run back for replay.
534pub fn replay(records_root: &Path, run_id: &str) -> Result<(RunRecord, Vec<Event>)> {
535    let dir = records_root.join("runs").join(run_id);
536    let record_text = std::fs::read_to_string(dir.join("record.json"))
537        .map_err(|e| Error::Other(format!("no run {run_id:?}: {e}")))?;
538    let record: RunRecord = serde_json::from_str(&record_text)
539        .map_err(|e| Error::Other(format!("run record is unreadable: {e}")))?;
540    let events = crate::record::read_events(&dir)?;
541    Ok((record, events))
542}