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    let repo = places.repo;
82    let run_id = format!("{}-{}", task.id, now_rfc3339().replace([':', '-'], ""));
83    let mut log = RunLog::create(places.records, &run_id)?.watched_by(watcher);
84    log.enter(Phase::Isolating);
85
86    let mut record = RunRecord {
87        run_id: run_id.clone(),
88        task_id: task.id.clone(),
89        prompt: task.prompt.clone(),
90        author: task.author.clone(),
91        adapter: routing.author.id().to_string(),
92        repository: places.name.to_string(),
93        started_at: now_rfc3339(),
94        finished_at: None,
95        checks: Vec::new(),
96        approval: None,
97        usage: Vec::new(),
98        outcome: None,
99    };
100
101    // 1. Isolate. Work is a diff on disk before it is anything else.
102    let wt = worktree::create(repo, places.worktrees, &run_id, &task.base_ref)?;
103
104    // 2. Make the checkout usable. A worktree is a fresh checkout, so whatever
105    //    git ignores is missing from it — and the agent needs the project's
106    //    tools as much as the gate does.
107    log.enter(Phase::Preparing);
108    match worktree::prepare(
109        repo,
110        wt.path(),
111        &config.worktree,
112        places.notes,
113        places.skills,
114        config.gate.timeout_secs.map(std::time::Duration::from_secs),
115    ) {
116        Ok(steps) => {
117            for step in steps {
118                log.append(&Event::Message {
119                    text: format!("prepared: {step}"),
120                    raw: None,
121                })?;
122            }
123        }
124        Err(problem) => {
125            return finish(
126                log,
127                record,
128                Outcome::Failed,
129                None,
130                Some(Refusal::SetupFailed {
131                    step: problem.step,
132                    reason: problem.reason,
133                }),
134                String::new(),
135            );
136        }
137    }
138
139    // 3. Execute, streaming events into the log as they arrive so an
140    //    interrupted run still leaves an account of how far it got.
141    log.enter(Phase::Authoring);
142    // A separate spec, the way review builds one. `task.prompt` is the
143    // operator's sentence and it is read again further down — by the record and
144    // by the commit message — so composing in place would put this preamble in
145    // both, where it is neither what was asked nor part of the audit trail.
146    // Read off the worktree rather than off the configuration. Naming notes in
147    // `[worktree]` is an intention; a repository that tracks its own `notes/`
148    // keeps it, and telling an agent otherwise would point it at the diff.
149    let authoring = TaskSpec {
150        prompt: author::author_prompt(
151            &task.prompt,
152            worktree::linked(wt.path(), "notes"),
153            worktree::linked(wt.path(), "skills"),
154        ),
155        ..task.clone()
156    };
157    let author = drive(routing.author.as_ref(), &authoring, wt.path(), &mut log)?;
158    record.usage.extend(author.usage.clone());
159
160    // 3. Read what was actually touched, from git rather than from the agent.
161    let touched = worktree::touched_paths(wt.path())?;
162    log.append(&Event::Finished {
163        exit_code: author.exit_code,
164        files_touched: touched.clone(),
165    })?;
166
167    // Nothing was written, so there is nothing a reviewer can rule on: it would
168    // be handed an empty diff and asked what it thinks of it, which spends a
169    // second vendor to produce a confused answer and then reports that answer
170    // as the reason. Why it is empty is the reason, and it is known here.
171    // A killed author is refused whether or not it left something behind: what
172    // is on disk is half of whatever it was doing, and half a change is not a
173    // change anybody should be asked to review.
174    if author.interrupted {
175        return finish(
176            log,
177            record,
178            Outcome::Rejected,
179            None,
180            Some(Refusal::Interrupted),
181            String::new(),
182        );
183    }
184
185    if author.timed_out {
186        return finish(
187            log,
188            record,
189            Outcome::Rejected,
190            None,
191            Some(Refusal::TimedOut {
192                after_secs: config.policy.timeout_secs.unwrap_or_default(),
193            }),
194            String::new(),
195        );
196    }
197
198    // An author that did not exit cleanly did not finish, and what is on disk
199    // is half of whatever it was doing. Half a change is not a change anybody
200    // should be asked to review — the same reasoning that refuses a killed
201    // author, and the same situation: a context window running out is a stop
202    // like any other, and only who stopped it differs.
203    //
204    // Being this strict costs something, and it is worth naming: a vendor that
205    // exits non-zero for a harmless reason now has finished work refused
206    // rather than reviewed. That is the safe direction and it is recoverable —
207    // a refused run keeps its worktree, the record carries the vendor's own
208    // words, and running it again is one command. The unsafe direction is not:
209    // a half-written change that happened to compile was gated, reviewed and
210    // approved, which is what this used to do.
211    if author.exit_code != Some(0) {
212        let refusal = Refusal::AuthorFailed {
213            code: author
214                .exit_code
215                .map(|c| c.to_string())
216                .unwrap_or_else(|| "no exit code".to_string()),
217            diagnostics: author.diagnostics.clone(),
218        };
219        return finish(
220            log,
221            record,
222            Outcome::Rejected,
223            None,
224            Some(refusal),
225            String::new(),
226        );
227    }
228
229    if touched.is_empty() {
230        return finish(
231            log,
232            record,
233            Outcome::Rejected,
234            None,
235            Some(Refusal::NoChange),
236            String::new(),
237        );
238    }
239
240    if !config.policy.permits(&touched) {
241        return finish(
242            log,
243            record,
244            Outcome::Rejected,
245            None,
246            Some(Refusal::PolicyViolation {
247                reason: format!("policy forbids writing outside declared paths: {touched:?}"),
248            }),
249            String::new(),
250        );
251    }
252
253    // 4. Gate. These commands actually run; their output is captured.
254    log.enter(Phase::Gating);
255    let passed = match gate::run_checks(&config.gate, wt.path(), &mut |record| {
256        log_checked(&mut log, record)
257    }) {
258        Ok(p) => p,
259        Err(refusal) => {
260            // Keep the evidence. A failed run is the one someone will need to
261            // read afterwards, so the records travel from the refusal into the
262            // record before anything returns.
263            if let Refusal::ChecksFailed { records, .. } = &refusal {
264                record.checks.clone_from(records);
265            }
266            return finish(
267                log,
268                record,
269                Outcome::Rejected,
270                None,
271                Some(refusal),
272                String::new(),
273            );
274        }
275    };
276    record.checks = passed.records().to_vec();
277
278    // 5. Review, by an adapter that is not the one that wrote the change.
279    log.enter(Phase::Reviewing);
280    let diff = worktree::diff(wt.path())?;
281    let (verdict, reviewer_usage) = collect_verdict(
282        routing.reviewer.as_ref(),
283        task,
284        &diff,
285        &record.checks,
286        wt.path(),
287        &mut log,
288    )?;
289    record.usage.extend(reviewer_usage);
290    let approval = Approval {
291        reviewer: reviewer_identity.clone(),
292        verdict: verdict.clone(),
293    };
294    record.approval = Some(approval.clone());
295
296    // 6. The gate decides. Nothing above this line can mint a token.
297    match gate::evaluate(
298        passed,
299        &task.author,
300        &approval,
301        config.gate.review.must_differ_from_author,
302    ) {
303        Ok(token) => {
304            // The trailers are the audit trail in the place it survives longest:
305            // a commit outlives the run directory it came from.
306            let message = format!(
307                "{}\n\nRun: {run_id}\nAuthored-by: {} ({})\nReviewed-by: {} ({})",
308                task.prompt,
309                task.author,
310                routing.author.id(),
311                reviewer_identity,
312                routing.reviewer.id(),
313            );
314            worktree::commit(wt.path(), &message, &task.author)?;
315            // The commit is on the run's branch now, and everything downstream
316            // reads it from there: the diff pane, `replay`, and promotion. The
317            // checkout is redundant, and a directory per run is how a busy
318            // repository fills a disk with copies of itself.
319            //
320            // Only on success. A refused run's worktree is the evidence someone
321            // needs to see what went wrong, and deleting it would take that
322            // away at exactly the moment it matters.
323            if let Err(e) = worktree::release(repo, &wt) {
324                log.append(&Event::Error {
325                    message: format!("the worktree could not be removed: {e}"),
326                    raw: None,
327                })?;
328            }
329            finish(log, record, Outcome::Approved, Some(token), None, diff)
330        }
331        Err(refusal) => finish(log, record, Outcome::Rejected, None, Some(refusal), diff),
332    }
333}
334
335/// Reports one finished check.
336///
337/// A free function rather than a closure body so the borrow of the log inside
338/// `run_checks` stays a single obvious line.
339fn log_checked(log: &mut RunLog, record: &ostraka_core::gate::CheckRecord) {
340    log.checked(record);
341}
342
343/// Runs an adapter to completion, logging every event.
344fn drive(
345    adapter: &dyn VendorAdapter,
346    task: &TaskSpec,
347    worktree: &Path,
348    log: &mut RunLog,
349) -> Result<AdapterOutcome> {
350    let mut session = adapter.launch(task, worktree)?;
351    while let Some(event) = session.next_event() {
352        log.append(&event)?;
353    }
354    let outcome = session.finish();
355    // Recorded whether or not anything else reads it. A run that ended badly
356    // and whose cause was discarded looks like an agent that simply did
357    // nothing, and the transcript is where somebody goes to find out which.
358    if let Some(diagnostics) = &outcome.diagnostics {
359        log.append(&Event::Error {
360            message: format!("author exited abnormally: {diagnostics}"),
361            raw: None,
362        })?;
363    }
364    Ok(outcome)
365}
366
367/// Runs the reviewer and reads its answer.
368///
369/// Fail-safe throughout: a reviewer that cannot be launched, or that says
370/// nothing usable, has not approved anything.
371type Reviewed = (Verdict, Option<ostraka_core::record::TokenUsage>);
372
373fn collect_verdict(
374    reviewer: &dyn VendorAdapter,
375    task: &TaskSpec,
376    diff: &str,
377    checks: &[ostraka_core::gate::CheckRecord],
378    worktree: &Path,
379    log: &mut RunLog,
380) -> Result<Reviewed> {
381    // Derived here, after the author has finished, and handed only to the
382    // reviewer: it is what lets the verdict be read from anywhere in the answer
383    // without an author being able to plant one in the diff.
384    let marker = review::verdict_marker(&task.id);
385    let review_task = TaskSpec {
386        id: format!("{}-review", task.id),
387        prompt: review::review_prompt(&task.prompt, diff, checks, &marker),
388        adapter: reviewer.id().to_string(),
389        author: task.author.clone(),
390        base_ref: task.base_ref.clone(),
391        model: None,
392    };
393
394    let mut session = match reviewer.launch(&review_task, worktree) {
395        Ok(s) => s,
396        Err(e) => {
397            return Ok((
398                Verdict::Reject {
399                    reason: format!("reviewer could not be launched: {e}"),
400                },
401                None,
402            ));
403        }
404    };
405
406    let mut spoken = String::new();
407    while let Some(event) = session.next_event() {
408        if let Event::Message { text, .. } = &event {
409            spoken.push_str(text);
410            spoken.push('\n');
411        }
412        log.append(&event)?;
413    }
414    let outcome = session.finish();
415    if outcome.exit_code != Some(0) {
416        let code = outcome
417            .exit_code
418            .map(|c| c.to_string())
419            .unwrap_or_else(|| "no exit code".to_string());
420        // Say why. A reviewer that failed because a credential expired and one
421        // that failed because it disagreed are the same exit code, and only one
422        // of them is about the change.
423        let reason = match &outcome.diagnostics {
424            Some(d) => format!("reviewer could not run (exit {code}): {d}"),
425            None => format!("reviewer could not run (exit {code}), and said nothing"),
426        };
427        log.append(&Event::Error {
428            message: reason.clone(),
429            raw: None,
430        })?;
431        return Ok((Verdict::Reject { reason }, outcome.usage));
432    }
433
434    Ok((review::parse_verdict(&spoken, &marker), outcome.usage))
435}
436
437fn finish(
438    log: RunLog,
439    mut record: RunRecord,
440    outcome: Outcome,
441    token: Option<MergeToken>,
442    refusal: Option<Refusal>,
443    diff: String,
444) -> Result<RunReport> {
445    record.finished_at = Some(now_rfc3339());
446    record.outcome = Some(outcome);
447    log.write_record(&record)?;
448    Ok(RunReport {
449        record,
450        token,
451        refusal,
452        diff,
453    })
454}
455
456/// Reads a previous run back for replay.
457pub fn replay(records_root: &Path, run_id: &str) -> Result<(RunRecord, Vec<Event>)> {
458    let dir = records_root.join("runs").join(run_id);
459    let record_text = std::fs::read_to_string(dir.join("record.json"))
460        .map_err(|e| Error::Other(format!("no run {run_id:?}: {e}")))?;
461    let record: RunRecord = serde_json::from_str(&record_text)
462        .map_err(|e| Error::Other(format!("run record is unreadable: {e}")))?;
463    let events = crate::record::read_events(&dir)?;
464    Ok((record, events))
465}