Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,8 @@ A Run that asks for a notification, by the flag or by `email.always`, makes the
- **Subject**: `[thirdshift] <owner>/<repo>#<n> <issue title>: <outcome>`, where the outcome is `ready for review`, `merged`, `failed` or `interrupted`. The title is left out if it can't be read from GitHub.
- **Body**, plain text: a `Result:` line with the same outcome as the subject, so it is readable even when a long issue title hides the end of the subject; then the pull request URL (if any), the failure cause (if failed), after an Inherited failure with no Base fix taken its [`Base check:`, `Retry with:` and `Or set:` lines](#an-inherited-failure-links-the-base-branchs-checks), the session log path (if any), the [Command log](#logs) path as a `Command log:` line, the hostname and how long the Run took.

The `Built with` line names the command's configured Harness, Model and Effort. A `Session models` section names each session's Harness and Models reported by its output or retained records, including model switches and delegated Claude answers. It covers failed or interrupted sessions, Resumes and child Runs belonging to that Command. When the Harness supplies no model evidence, the requested Model is labelled `(requested)`, or its default is labelled `(not reported)`. Effort is the session's configured Effort; it does not describe a delegated answer's Effort. Attribution records (`*.models.json`) are kept beside the Session logs; a failure to write or read them is a warning and never changes the Run's outcome.

A [Spec run](#spec-runs) sends at most one notification for the whole Spec, under the same rules, with its checks made once before any Ticket starts. Its subject names the Spec, its outcome is the Spec run's, and its body, after what a Run's holds (the Spec PR, if any), lists each Ticket's outcome, one line per Ticket as in the summary on stderr, such as `#21 landed with https://github.com/acme/widgets/pull/1` or `#22 blocked by #21`. The Ticket Runs inside it never send a notification of their own, whatever the User config says.

An [Architect run](#architect-runs) takes the same flags and the same `email.always`, and sends [one notification](#one-run-notification) covering its review and the run it dispatched.
Expand Down
1 change: 1 addition & 0 deletions src/child_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ fn start_using(
startup: impl FnOnce(&mut Handle) -> Result<()>,
) -> Result<Handle> {
let mut command = Command::new(executable);
crate::session::models::inherit_command(&mut command);
// On Linux `executable` is a link, and the child goes by this process's
// command instead.
if cfg!(target_os = "linux")
Expand Down
17 changes: 12 additions & 5 deletions src/harness/interpretation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::interrupt;

mod stream;
pub(super) use stream::Stream;
mod models;
mod security;
pub use security::SafeguardRefusal;
use security::Security;
Expand All @@ -21,6 +22,7 @@ pub struct Interpretation {
decoder: Box<dyn Decoder>,
retained: Retained,
security: Option<Security>,
models: models::Models,
}

/// Reporting survives ordinary failures; only success supplies Resume facts.
Expand All @@ -33,7 +35,7 @@ pub struct Completion {
pub struct Report {
pub warnings: Vec<String>,
pub summary: Option<String>,
/// Answering Models recovered from retained records, in response order.
/// Models reported by session output or retained records.
pub models: Vec<String>,
}

Expand Down Expand Up @@ -170,6 +172,7 @@ impl Interpretation {
decoder,
retained,
security: None,
models: models::Models::new(cli),
}
}

Expand All @@ -179,9 +182,16 @@ impl Interpretation {
self
}

/// Stream evidence already read, even when interruption suppresses
/// completion reporting and retained-record reads.
pub fn observed_models(&self) -> Vec<String> {
self.models.names().to_vec()
}

/// Unknown or malformed lines produce no progress, never an error.
pub fn condense(&mut self, raw: &str) -> Vec<String> {
let mut lines = self.decoder.condense(raw);
self.models.observe(raw);
if let Some(security) = &mut self.security
&& let Some(line) = security.observe(raw)
{
Expand Down Expand Up @@ -218,16 +228,13 @@ impl Interpretation {
if let Err(error) = interrupt::check() {
return (None, Err(Failure::Execution(error)));
}
let security_session = self.security.is_some();
let mut facts = self.decoder.complete();
facts.report.models = self.models.into_names();
let refusal = self.security.and_then(|security| security.refusal);
if refusal.is_some() {
facts.outcome = TurnOutcome::Failed;
}
let recovered = self.retained.reconcile(&mut facts);
if !security_session {
facts.report.models.clear();
}
if let Err(error) = interrupt::check() {
return (None, Err(Failure::Execution(error)));
}
Expand Down
68 changes: 68 additions & 0 deletions src/harness/interpretation/models.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! Models named by session evidence, including delegated Claude answers.
//! Requested defaults are reported by the session owner, never as observations.

use serde_json::Value;

use crate::harness::Harness;

pub(super) struct Models {
harness: Option<Harness>,
names: Vec<String>,
}

impl Models {
pub fn new(cli: &str) -> Self {
Self {
harness: Harness::named(cli),
names: Vec::new(),
}
}

pub fn observe(&mut self, raw: &str) {
let Ok(event) = serde_json::from_str::<Value>(raw) else {
return;
};
if let Some(model) = reported_model(self.harness, &event) {
self.include(model);
}
if self.harness == Some(Harness::Claude)
&& event["type"] == "result"
&& let Some(usage) = event["modelUsage"].as_object()
{
for model in usage.keys() {
self.include(model);
}
}
}

fn include(&mut self, model: &str) {
if !model.is_empty()
&& model != "<synthetic>"
&& !self.names.iter().any(|name| name == model)
{
self.names.push(model.to_string());
}
}

pub fn into_names(self) -> Vec<String> {
self.names
}

pub fn names(&self) -> &[String] {
&self.names
}
}

/// Shared protocol evidence. Callers choose whether delegated answers count.
pub(super) fn reported_model(harness: Option<Harness>, event: &Value) -> Option<&str> {
let model = match harness {
Some(Harness::Claude | Harness::Grok)
if event["type"] == "assistant" && event["is_api_error_message"] != true =>
{
event["message"]["model"].as_str()
}
Some(Harness::Agy) if event["event"] == "init" => event["init"]["model"].as_str(),
_ => None,
};
model.filter(|model| !model.is_empty() && *model != "<synthetic>")
}
31 changes: 9 additions & 22 deletions src/harness/interpretation/security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ impl Security {

pub fn observe(&mut self, raw: &str) -> Option<String> {
let event = serde_json::from_str::<Value>(raw).ok()?;
let model = match self.harness {
match self.harness {
Some(Harness::Claude) => {
let refused = match event["type"].as_str() {
Some("system") => {
Expand All @@ -66,9 +66,7 @@ impl Security {
if refused {
self.refusal = Some(SafeguardRefusal::ClaudeCyber);
}
Self::answer_model(&event)
}
Some(Harness::Grok) => Self::answer_model(&event),
Some(Harness::Codex) => {
if event["type"] == "turn.failed"
&& event["error"]["message"]
Expand All @@ -88,29 +86,18 @@ impl Security {
self.last_model = Some(model.to_string());
return Some(line);
}
None
}
Some(Harness::Agy) if event["event"] == "init" => event["init"]["model"].as_str(),
_ => None,
}?;
if model.is_empty() || self.last_model.as_deref() == Some(model) {
_ => {}
}
// Init names the requested Model; result.modelUsage includes sub-agents.
// Security progress names only main-loop answers, while notification
// attribution also counts delegated answers.
let model = super::models::reported_model(self.harness, &event)
.filter(|_| event["parent_tool_use_id"].is_null())?;
if self.last_model.as_deref() == Some(model) {
return None;
}
self.last_model = Some(model.to_string());
Some(format!("Model: {model}"))
}

// Init names the requested Model; result.modelUsage includes sub-agents.
// Only main-loop assistant messages identify the Model that answered.
fn answer_model(event: &Value) -> Option<&str> {
if event["type"] != "assistant"
|| !event["parent_tool_use_id"].is_null()
|| event["is_api_error_message"] == true
{
return None;
}
event["message"]["model"]
.as_str()
.filter(|model| *model != "<synthetic>")
}
}
16 changes: 15 additions & 1 deletion src/notification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,27 @@ impl RunNotification {
};
let host = host::name();
let command_log = logs::command_log_path();
let text = body(
let mut text = body(
account,
self.built_with.as_deref(),
command_log.as_deref(),
host.as_deref().unwrap_or("unknown host"),
self.checked.started.elapsed(),
);
if let Some(log) = &command_log {
match crate::session::models::notification_lines(log) {
Ok(lines) if !lines.is_empty() => {
text += "\nSession models:\n";
for line in lines {
text += &format!("{line}\n");
}
}
Ok(_) => {}
Err(error) => progress::step(format_args!(
"warning: could not read session models for the Run notification: {error:#}"
)),
}
}
if let Err(error) = self.checked.resend.send(&subject, &text) {
progress::step(format_args!(
"warning: could not send the Run notification: {error:#}"
Expand Down
37 changes: 32 additions & 5 deletions src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use crate::progress;
use crate::prompt;
use crate::skills;

pub mod models;
mod review_reports;
use review_reports::ReviewReports;

Expand Down Expand Up @@ -330,7 +331,7 @@ impl Outside for OnMachine {
};
run(
kind,
self.adapter,
&self.harness,
&self.worktree,
invocation,
log,
Expand Down Expand Up @@ -364,12 +365,13 @@ fn ending_with(killed: &[&str]) -> String {
/// and fails with `interrupted`.
fn run(
kind: &str,
adapter: &'static dyn Adapter,
choice: &Choice,
worktree: &Path,
invocation: Invocation,
log: &Path,
stream: Interpretation,
) -> Result<Ended> {
let adapter = choice.harness.adapter();
let cli = adapter.name();
if let Some(dir) = log.parent() {
fs::create_dir_all(dir).with_context(|| format!("could not create {}", dir.display()))?;
Expand All @@ -378,20 +380,45 @@ fn run(
File::create(log).with_context(|| format!("could not create {}", log.display()))?;
let started = Instant::now();
let (kind_owned, log_owned) = (kind.to_string(), log.to_path_buf());
let choice_owned = choice.clone();
let executed = process::streaming(
adapter,
Command::new(cli)
.args(&invocation.args)
.current_dir(worktree),
invocation.stdin.as_deref(),
stream,
move |output, stream| follow(&kind_owned, output, &mut log_file, &log_owned, stream),
move |output, stream| {
let followed = follow(&kind_owned, output, &mut log_file, &log_owned, stream);
// The process owner joins this reader before returning, including
// on interruption, when it deliberately suppresses state recovery.
models::keep(
&kind_owned,
&log_owned,
&choice_owned,
stream.observed_models(),
);
followed
},
)?;

let completion = executed.state.finish(executed.execution);
let report_models = completion
.report
.as_ref()
.map(|report| report.models.clone())
.unwrap_or_default();
models::keep(kind, log, choice, report_models);
if let Some(report) = completion.report {
for model in report.models {
progress::step(format_args!("{kind}: Model: {model}"));
// Stream models already have security progress lines. Retained records
// need theirs here, as before.
if matches!(
choice.harness,
crate::harness::Harness::Muse | crate::harness::Harness::OpenCode
) {
for model in report.models {
progress::step(format_args!("{kind}: Model: {model}"));
}
}
for warning in report.warnings {
progress::step(format_args!("{kind}: {warning}"));
Expand Down
Loading
Loading