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}