ostraka-runtime 1.3.0

The Ostraka engine: worktrees, gate execution, independent review, run records.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
//! One task, start to finish.
//!
//! Isolate, execute, gate, review, record. The orchestrator drives every step
//! and can approve none of them: the only thing that ends a run favourably is a
//! [`crate::gate::MergeToken`], which this module cannot construct.

use crate::author;
use crate::gate::{self, MergeToken, Refusal};
use crate::progress::{Phase, Watcher};
use crate::record::RunLog;
use crate::review;
use crate::route::Routing;
use crate::worktree;
use crate::{Error, Result};
use ostraka_adapter::{AdapterOutcome, VendorAdapter};
use ostraka_core::clock::now_rfc3339;
use ostraka_core::config::Config;
use ostraka_core::gate::{Approval, Verdict};
use ostraka_core::identity::ActorId;
use ostraka_core::record::{Event, Outcome, RunRecord};
use ostraka_core::task::TaskSpec;
use std::path::Path;

/// What a finished run produced.
pub struct RunReport {
    pub record: RunRecord,
    /// Present only when the gate minted one. Its absence is the refusal.
    pub token: Option<MergeToken>,
    pub refusal: Option<Refusal>,
    pub diff: String,
}

impl RunReport {
    pub fn approved(&self) -> bool {
        self.token.is_some()
    }
}

/// Where the parts of a run live.
///
/// Three directories that used to be one: a run is made *in* a repository,
/// *beside* a set of worktrees, and *recorded* somewhere that may be neither.
/// Deriving the second two from the first assumed a workspace holding exactly
/// one repository, which is the assumption this replaces — and passing them
/// explicitly means the layout is decided by whoever knows it rather than
/// rebuilt from a convention in here.
pub struct Places<'a> {
    /// The repository the change is made in.
    pub repo: &'a Path,
    /// Where worktrees are created.
    pub worktrees: &'a Path,
    /// Where run records are written.
    pub records: &'a Path,
    /// The name this repository is known by, for the record.
    pub name: &'a str,
    /// The workspace's notes, linked into the worktree so what an agent works
    /// out survives the run. `None` where there are none.
    pub notes: Option<&'a Path>,
    /// The workspace's skills, linked in the same way and for the mirror
    /// reason: what people wrote down for a run to follow. `None` where there
    /// are none.
    pub skills: Option<&'a Path>,
}

/// Runs one task through the whole pipeline.
///
/// The worktree is left in place on completion so the diff can be inspected;
/// removing it is the caller's decision, not this function's.
///
/// `watcher` is told what is happening while it happens, for a caller that has
/// a screen to keep up to date. It cannot change any of it: see
/// [`crate::progress`]. `None` behaves exactly as this function always has.
pub fn run_task(
    places: &Places<'_>,
    config: &Config,
    routing: &Routing,
    task: &TaskSpec,
    reviewer_identity: &ActorId,
    watcher: Option<Box<dyn Watcher>>,
) -> Result<RunReport> {
    run_task_until(
        places,
        config,
        routing,
        task,
        reviewer_identity,
        watcher,
        &ostraka_adapter::interrupt::Stop::new(),
    )
}

/// [`run_task`], for a run that can be stopped without stopping any other.
///
/// `stop` is the same one `routing` was built with by
/// [`crate::route::select_until`]: the adapters answer to it through the
/// routing, and the gate's checks answer to it here. Ctrl-C still stops every
/// run, because a `Stop` also answers to the process-wide request.
pub fn run_task_until(
    places: &Places<'_>,
    config: &Config,
    routing: &Routing,
    task: &TaskSpec,
    reviewer_identity: &ActorId,
    watcher: Option<Box<dyn Watcher>>,
    stop: &ostraka_adapter::interrupt::Stop,
) -> Result<RunReport> {
    let repo = places.repo;
    let run_id = format!("{}-{}", task.id, now_rfc3339().replace([':', '-'], ""));
    let mut log = RunLog::create(places.records, &run_id)?.watched_by(watcher);
    log.enter(Phase::Isolating);

    let mut record = RunRecord {
        run_id: run_id.clone(),
        task_id: task.id.clone(),
        prompt: task.prompt.clone(),
        author: task.author.clone(),
        adapter: routing.author.id().to_string(),
        repository: places.name.to_string(),
        started_at: now_rfc3339(),
        finished_at: None,
        checks: Vec::new(),
        approval: None,
        usage: Vec::new(),
        outcome: None,
    };

    // 1. Isolate. Work is a diff on disk before it is anything else.
    let wt = worktree::create(repo, places.worktrees, &run_id, &task.base_ref)?;

    // 2. Make the checkout usable. A worktree is a fresh checkout, so whatever
    //    git ignores is missing from it — and the agent needs the project's
    //    tools as much as the gate does.
    log.enter(Phase::Preparing);
    match worktree::prepare(
        repo,
        wt.path(),
        &config.worktree,
        places.notes,
        places.skills,
        config.gate.timeout_secs.map(std::time::Duration::from_secs),
    ) {
        Ok(steps) => {
            for step in steps {
                log.append(&Event::Message {
                    text: format!("prepared: {step}"),
                    raw: None,
                })?;
            }
        }
        Err(problem) => {
            return finish(
                log,
                record,
                Outcome::Failed,
                None,
                Some(Refusal::SetupFailed {
                    step: problem.step,
                    reason: problem.reason,
                }),
                String::new(),
            );
        }
    }

    // 3. Execute, streaming events into the log as they arrive so an
    //    interrupted run still leaves an account of how far it got.
    log.enter(Phase::Authoring);
    // A separate spec, the way review builds one. `task.prompt` is the
    // operator's sentence and it is read again further down — by the record and
    // by the commit message — so composing in place would put this preamble in
    // both, where it is neither what was asked nor part of the audit trail.
    // Read off the worktree rather than off the configuration. Naming notes in
    // `[worktree]` is an intention; a repository that tracks its own `notes/`
    // keeps it, and telling an agent otherwise would point it at the diff.
    let authoring = TaskSpec {
        prompt: author::author_prompt(
            &task.prompt,
            worktree::linked(wt.path(), "notes"),
            worktree::linked(wt.path(), "skills"),
        ),
        ..task.clone()
    };
    let author = drive(routing.author.as_ref(), &authoring, wt.path(), &mut log)?;
    record.usage.extend(author.usage.clone());

    // 3. Read what was actually touched, from git rather than from the agent.
    let touched = worktree::touched_paths(wt.path())?;
    log.append(&Event::Finished {
        exit_code: author.exit_code,
        files_touched: touched.clone(),
    })?;

    // Nothing was written, so there is nothing a reviewer can rule on: it would
    // be handed an empty diff and asked what it thinks of it, which spends a
    // second vendor to produce a confused answer and then reports that answer
    // as the reason. Why it is empty is the reason, and it is known here.
    // A killed author is refused whether or not it left something behind: what
    // is on disk is half of whatever it was doing, and half a change is not a
    // change anybody should be asked to review.
    if author.interrupted {
        return finish(
            log,
            record,
            Outcome::Rejected,
            None,
            Some(Refusal::Interrupted),
            String::new(),
        );
    }

    if author.timed_out {
        return finish(
            log,
            record,
            Outcome::Rejected,
            None,
            Some(Refusal::TimedOut {
                after_secs: config.policy.timeout_secs.unwrap_or_default(),
            }),
            String::new(),
        );
    }

    // An author that did not exit cleanly did not finish, and what is on disk
    // is half of whatever it was doing. Half a change is not a change anybody
    // should be asked to review — the same reasoning that refuses a killed
    // author, and the same situation: a context window running out is a stop
    // like any other, and only who stopped it differs.
    //
    // Being this strict costs something, and it is worth naming: a vendor that
    // exits non-zero for a harmless reason now has finished work refused
    // rather than reviewed. That is the safe direction and it is recoverable —
    // a refused run keeps its worktree, the record carries the vendor's own
    // words, and running it again is one command. The unsafe direction is not:
    // a half-written change that happened to compile was gated, reviewed and
    // approved, which is what this used to do.
    if author.exit_code != Some(0) {
        let refusal = Refusal::AuthorFailed {
            code: author
                .exit_code
                .map(|c| c.to_string())
                .unwrap_or_else(|| "no exit code".to_string()),
            diagnostics: author.diagnostics.clone(),
        };
        return finish(
            log,
            record,
            Outcome::Rejected,
            None,
            Some(refusal),
            String::new(),
        );
    }

    if touched.is_empty() {
        return finish(
            log,
            record,
            Outcome::Rejected,
            None,
            Some(Refusal::NoChange),
            String::new(),
        );
    }

    if !config.policy.permits(&touched) {
        return finish(
            log,
            record,
            Outcome::Rejected,
            None,
            Some(Refusal::PolicyViolation {
                reason: format!("policy forbids writing outside declared paths: {touched:?}"),
            }),
            String::new(),
        );
    }

    // 4. Gate. These commands actually run; their output is captured.
    log.enter(Phase::Gating);
    let passed = match gate::run_checks_until(&config.gate, wt.path(), stop, &mut |record| {
        log_checked(&mut log, record)
    }) {
        Ok(p) => p,
        Err(refusal) => {
            // Keep the evidence. A failed run is the one someone will need to
            // read afterwards, so the records travel from the refusal into the
            // record before anything returns.
            if let Refusal::ChecksFailed { records, .. } = &refusal {
                record.checks.clone_from(records);
            }
            return finish(
                log,
                record,
                Outcome::Rejected,
                None,
                Some(refusal),
                String::new(),
            );
        }
    };
    record.checks = passed.records().to_vec();

    // 5. Freeze the change. What the gate left behind is what gets reviewed,
    // and what gets reviewed is the only thing an approval may commit.
    let diff = worktree::diff(wt.path())?;
    let reviewed = worktree::tree(wt.path())?;

    // Policy again, on the change as it now stands. The check above saw what
    // the author touched before any check ran; a check that writes a file
    // outside the declared paths put it in this diff and in the commit without
    // policy ever looking.
    let staged = worktree::staged_paths(wt.path())?;
    if !config.policy.permits(&staged) {
        return finish(
            log,
            record,
            Outcome::Rejected,
            None,
            Some(Refusal::PolicyViolation {
                reason: format!(
                    "the change as it stood after the gate writes outside declared paths: \
                     {staged:?}"
                ),
            }),
            diff,
        );
    }

    // 6. Review, by an adapter that is not the one that wrote the change.
    log.enter(Phase::Reviewing);
    let (verdict, reviewer_usage) = collect_verdict(
        routing.reviewer.as_ref(),
        task,
        &diff,
        &record.checks,
        wt.path(),
        &mut log,
    )?;
    record.usage.extend(reviewer_usage);
    let approval = Approval {
        reviewer: reviewer_identity.clone(),
        verdict: verdict.clone(),
    };
    record.approval = Some(approval.clone());

    // The reviewer ran inside the worktree it was judging, and two shipped
    // profiles have no read-only posture at all. So the tree is compared before
    // anything is minted: a worktree that no longer matches what was reviewed
    // has not been reviewed, whatever the verdict says — and committing it
    // would put a reviewer's own ungated, unreviewed edit under an approval.
    // Refused rather than repaired: resetting to the reviewed tree would hide
    // that a reviewer wrote to a change it was only meant to read, and the
    // worktree a refused run keeps is the evidence of exactly that.
    let after = worktree::restage(wt.path())?;
    if after != reviewed {
        return finish(
            log,
            record,
            Outcome::Rejected,
            None,
            Some(Refusal::PolicyViolation {
                reason: format!(
                    "the worktree changed while it was being reviewed (tree {reviewed} was \
                     reviewed, {after} is what is there now); a reviewer must not write to the \
                     change it judges, so nothing was committed"
                ),
            }),
            diff,
        );
    }

    // 7. The gate decides. Nothing above this line can mint a token.
    match gate::evaluate(
        passed,
        &task.author,
        &approval,
        config.gate.review.must_differ_from_author,
    ) {
        Ok(token) => {
            // The trailers are the audit trail in the place it survives longest:
            // a commit outlives the run directory it came from.
            let message = format!(
                "{}\n\nRun: {run_id}\nAuthored-by: {} ({})\nReviewed-by: {} ({})",
                task.prompt,
                task.author,
                routing.author.id(),
                reviewer_identity,
                routing.reviewer.id(),
            );
            worktree::commit(wt.path(), &message, &task.author)?;
            // The commit is on the run's branch now, and everything downstream
            // reads it from there: the diff pane, `replay`, and promotion. The
            // checkout is redundant, and a directory per run is how a busy
            // repository fills a disk with copies of itself.
            //
            // Only on success. A refused run's worktree is the evidence someone
            // needs to see what went wrong, and deleting it would take that
            // away at exactly the moment it matters.
            if let Err(e) = worktree::release(repo, &wt) {
                log.append(&Event::Error {
                    message: format!("the worktree could not be removed: {e}"),
                    raw: None,
                })?;
            }
            finish(log, record, Outcome::Approved, Some(token), None, diff)
        }
        Err(refusal) => finish(log, record, Outcome::Rejected, None, Some(refusal), diff),
    }
}

/// Reports one finished check.
///
/// A free function rather than a closure body so the borrow of the log inside
/// `run_checks` stays a single obvious line.
fn log_checked(log: &mut RunLog, record: &ostraka_core::gate::CheckRecord) {
    log.checked(record);
}

/// Runs an adapter to completion, logging every event.
fn drive(
    adapter: &dyn VendorAdapter,
    task: &TaskSpec,
    worktree: &Path,
    log: &mut RunLog,
) -> Result<AdapterOutcome> {
    let mut session = adapter.launch(task, worktree)?;
    while let Some(event) = session.next_event() {
        log.append(&event)?;
    }
    let outcome = session.finish();
    // Recorded whether or not anything else reads it. A run that ended badly
    // and whose cause was discarded looks like an agent that simply did
    // nothing, and the transcript is where somebody goes to find out which.
    if let Some(diagnostics) = &outcome.diagnostics {
        log.append(&Event::Error {
            message: format!("author exited abnormally: {diagnostics}"),
            raw: None,
        })?;
    }
    Ok(outcome)
}

/// Runs the reviewer and reads its answer.
///
/// Fail-safe throughout: a reviewer that cannot be launched, or that says
/// nothing usable, has not approved anything.
type Reviewed = (Verdict, Option<ostraka_core::record::TokenUsage>);

fn collect_verdict(
    reviewer: &dyn VendorAdapter,
    task: &TaskSpec,
    diff: &str,
    checks: &[ostraka_core::gate::CheckRecord],
    worktree: &Path,
    log: &mut RunLog,
) -> Result<Reviewed> {
    // Derived here, after the author has finished, and handed only to the
    // reviewer: it is what lets the verdict be read from anywhere in the answer
    // without an author being able to plant one in the diff.
    let marker = review::verdict_marker(&task.id);
    let review_task = TaskSpec {
        id: format!("{}-review", task.id),
        prompt: review::review_prompt(&task.prompt, diff, checks, &marker),
        adapter: reviewer.id().to_string(),
        author: task.author.clone(),
        base_ref: task.base_ref.clone(),
        model: None,
    };

    let mut session = match reviewer.launch(&review_task, worktree) {
        Ok(s) => s,
        Err(e) => {
            return Ok((
                Verdict::Reject {
                    reason: format!("reviewer could not be launched: {e}"),
                },
                None,
            ));
        }
    };

    let mut spoken = String::new();
    while let Some(event) = session.next_event() {
        if let Event::Message { text, .. } = &event {
            spoken.push_str(text);
            spoken.push('\n');
        }
        log.append(&event)?;
    }
    let outcome = session.finish();
    if outcome.exit_code != Some(0) {
        let code = outcome
            .exit_code
            .map(|c| c.to_string())
            .unwrap_or_else(|| "no exit code".to_string());
        // Say why. A reviewer that failed because a credential expired and one
        // that failed because it disagreed are the same exit code, and only one
        // of them is about the change.
        let reason = match &outcome.diagnostics {
            Some(d) => format!("reviewer could not run (exit {code}): {d}"),
            None => format!("reviewer could not run (exit {code}), and said nothing"),
        };
        log.append(&Event::Error {
            message: reason.clone(),
            raw: None,
        })?;
        return Ok((Verdict::Reject { reason }, outcome.usage));
    }

    Ok((review::parse_verdict(&spoken, &marker), outcome.usage))
}

fn finish(
    log: RunLog,
    mut record: RunRecord,
    outcome: Outcome,
    token: Option<MergeToken>,
    refusal: Option<Refusal>,
    diff: String,
) -> Result<RunReport> {
    record.finished_at = Some(now_rfc3339());
    record.outcome = Some(outcome);
    log.write_record(&record)?;
    Ok(RunReport {
        record,
        token,
        refusal,
        diff,
    })
}

/// Reads a previous run back for replay.
pub fn replay(records_root: &Path, run_id: &str) -> Result<(RunRecord, Vec<Event>)> {
    let dir = records_root.join("runs").join(run_id);
    let record_text = std::fs::read_to_string(dir.join("record.json"))
        .map_err(|e| Error::Other(format!("no run {run_id:?}: {e}")))?;
    let record: RunRecord = serde_json::from_str(&record_text)
        .map_err(|e| Error::Other(format!("run record is unreadable: {e}")))?;
    let events = crate::record::read_events(&dir)?;
    Ok((record, events))
}