mod asks;
mod runs;
pub use asks::ChildAsks;
pub use runs::Runs;
use std::ffi::OsString;
use std::io::{BufRead, BufReader, Read};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use crate::base_fix::BaseFixAsk;
use crate::harness::{Choice, ChosenBy, Harness};
use crate::interrupt;
use crate::issue::IssueUrl;
use crate::logs;
use crate::progress;
use crate::run_ending;
#[cfg(test)]
use execution_tests::Fault;
const POLL: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Kind {
Ticket { spec_branch: String },
BaseFix { base: String },
}
impl Kind {
pub fn base(&self) -> &str {
match self {
Kind::Ticket { spec_branch } => spec_branch,
Kind::BaseFix { base } => base,
}
}
fn hidden_argument(&self) -> &'static str {
match self {
Kind::Ticket { .. } => SPEC_BRANCH,
Kind::BaseFix { .. } => BASE_FIX_INTO,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Given {
pub kind: Kind,
pub stamp: String,
pub command: logs::CommandKind,
pub base_fix: BaseFixAsk,
pub asks: ChildAsks,
}
const SPEC_BRANCH: &str = "--spec-branch";
const BASE_FIX_INTO: &str = "--base-fix-into";
const STAMP: &str = "--stamp";
const LOGS_COMMAND: &str = "--logs-command";
const ALLOW_BASE_FIX: &str = "--allow-base-fix";
const SECURITY_REVIEW: &str = "--run-security-review";
const ALLOW_SECURITY_FIX: &str = "--allow-security-fix";
const OFFER_BASE_FIX: &str = "--offer-base-fix";
const SESSIONS_HARNESS: &str = "--sessions-harness";
const SESSIONS_MODEL: &str = "--sessions-model";
const SESSIONS_EFFORT: &str = "--sessions-effort";
impl Given {
fn to_args(&self) -> Vec<&str> {
let mut args = vec![
self.kind.hidden_argument(),
self.kind.base(),
STAMP,
&self.stamp,
LOGS_COMMAND,
self.command.folder(),
];
match &self.base_fix {
BaseFixAsk::Allow => args.push(ALLOW_BASE_FIX),
BaseFixAsk::Forbid => {}
BaseFixAsk::Undecided { retry } => args.extend([OFFER_BASE_FIX, retry]),
}
if self.asks.security_fix {
args.push(ALLOW_SECURITY_FIX);
}
if self.asks.security_review {
args.push(SECURITY_REVIEW);
}
let harness = &self.asks.harness;
args.extend([SESSIONS_HARNESS, harness.harness.name()]);
if let Some(model) = &harness.model {
args.extend([SESSIONS_MODEL, model]);
}
if let Some(effort) = &harness.effort {
args.extend([SESSIONS_EFFORT, effort]);
}
args
}
}
#[derive(Default)]
pub struct Reader {
kind: Option<Kind>,
stamp: Option<String>,
command: Option<logs::CommandKind>,
base_fix: Option<BaseFixAsk>,
security_review: bool,
security_fix: bool,
harness: Option<Harness>,
model: Option<String>,
effort: Option<String>,
}
impl Reader {
pub fn take<'a>(
&mut self,
arg: &str,
rest: &mut impl Iterator<Item = &'a String>,
) -> Result<bool> {
let mut value = |missing: &str| match rest.next() {
Some(value) => Ok(value.clone()),
None => bail!("missing {missing}"),
};
match arg {
SECURITY_REVIEW => {
if self.security_review {
bail!("repeated argument: {arg}");
}
self.security_review = true;
}
ALLOW_SECURITY_FIX => {
if self.security_fix {
bail!("repeated argument: {arg}");
}
self.security_fix = true;
}
SPEC_BRANCH | BASE_FIX_INTO => {
not_yet_given(&self.kind, arg)?;
let base = value("Base branch")?;
self.kind = Some(if arg == SPEC_BRANCH {
Kind::Ticket { spec_branch: base }
} else {
Kind::BaseFix { base }
});
}
STAMP => {
not_yet_given(&self.stamp, arg)?;
self.stamp = Some(value("stamp")?);
}
LOGS_COMMAND => {
not_yet_given(&self.command, arg)?;
let name = value("logs command")?;
self.command = Some(logs::CommandKind::named(&name).with_context(|| {
format!("{arg} must name issue, pickup, architect or secure, not {name}")
})?);
}
ALLOW_BASE_FIX | OFFER_BASE_FIX => {
if let Some(given) = &self.base_fix
&& matches!(given, BaseFixAsk::Allow) != (arg == ALLOW_BASE_FIX)
{
bail!("{ALLOW_BASE_FIX} and {OFFER_BASE_FIX} can't be used together");
}
not_yet_given(&self.base_fix, arg)?;
self.base_fix = Some(if arg == ALLOW_BASE_FIX {
BaseFixAsk::Allow
} else {
BaseFixAsk::Undecided {
retry: value("command to offer")?,
}
});
}
SESSIONS_HARNESS => {
not_yet_given(&self.harness, arg)?;
let name = value("Harness")?;
let harness = Harness::named(&name)
.with_context(|| format!("{arg} must be followed by a Harness, not {name}"))?;
self.harness = Some(harness);
}
SESSIONS_MODEL => {
not_yet_given(&self.model, arg)?;
self.model = Some(value("Model")?);
}
SESSIONS_EFFORT => {
not_yet_given(&self.effort, arg)?;
self.effort = Some(value("Effort")?);
}
_ => return Ok(false),
}
Ok(true)
}
pub fn finish(self) -> Result<Option<Given>> {
let Some(kind) = self.kind else {
if self.command.is_some()
|| self.stamp.is_some()
|| self.base_fix.is_some()
|| self.security_review
|| self.security_fix
|| self.harness.is_some()
|| self.model.is_some()
|| self.effort.is_some()
{
bail!(
"only a child Run is given {STAMP}, {ALLOW_BASE_FIX}, {OFFER_BASE_FIX}, \
{SESSIONS_HARNESS}, {SESSIONS_MODEL}, {SESSIONS_EFFORT} or {LOGS_COMMAND}"
);
}
return Ok(None);
};
let stamp = self.stamp.with_context(|| format!("missing {STAMP}"))?;
let harness = self
.harness
.with_context(|| format!("missing {SESSIONS_HARNESS}"))?;
Ok(Some(Given {
kind,
stamp,
command: self
.command
.with_context(|| format!("missing {LOGS_COMMAND}"))?,
base_fix: self.base_fix.unwrap_or(BaseFixAsk::Forbid),
asks: ChildAsks {
harness: Choice {
harness,
model: self.model,
effort: self.effort,
chosen_by: ChosenBy::Command,
},
security_fix: self.security_fix,
security_review: self.security_review,
},
}))
}
}
fn not_yet_given<T>(given: &Option<T>, arg: &str) -> Result<()> {
if given.is_some() {
bail!("repeated argument: {arg}");
}
Ok(())
}
#[derive(Debug)]
pub enum Ended {
Reached {
pr_url: Option<String>,
base_fix: Option<String>,
},
Failed {
cause: String,
log: Option<String>,
local_work: Vec<String>,
},
Interrupted { local_work: Vec<String> },
}
pub struct Handle {
number: u64,
child: Child,
stdout: Option<Worker<String>>,
stderr: Option<Worker<run_ending::Reader>>,
status: Option<ExitStatus>,
reaped: bool,
stop_sent: bool,
interrupted: bool,
error: Option<anyhow::Error>,
completed: bool,
#[cfg(test)]
fault: Option<Fault>,
}
pub fn start(
issue: &IssueUrl,
kind: Kind,
base_fix: BaseFixAsk,
asks: &ChildAsks,
) -> Result<Handle> {
let given = Given {
kind,
stamp: logs::stamp(),
command: logs::command_kind(),
base_fix,
asks: asks.clone(),
};
start_from(&own_executable()?, issue, &given)
}
fn own_executable() -> Result<PathBuf> {
if cfg!(target_os = "linux") {
Ok(PathBuf::from("/proc/self/exe"))
} else {
std::env::current_exe().context("no thirdshift executable")
}
}
fn start_from(executable: &Path, issue: &IssueUrl, given: &Given) -> Result<Handle> {
start_using(executable, issue, given, Handle::start_readers)
}
fn start_using(
executable: &Path,
issue: &IssueUrl,
given: &Given,
startup: impl FnOnce(&mut Handle) -> Result<()>,
) -> Result<Handle> {
let mut command = Command::new(executable);
logs::inherit_record(&mut command);
crate::session::recording::Recording::capture().inherit_command(&mut command);
if cfg!(target_os = "linux")
&& let Some(own) = own_command()
{
command.arg0(own);
}
let child = command
.args(given.to_args())
.arg(&issue.url)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| {
let leads_to = match std::fs::read_link(executable) {
Ok(target) => format!(" ({})", target.display()),
Err(_) => String::new(),
};
format!(
"could not start the Run for #{} from {}{leads_to}",
issue.number,
executable.display()
)
})?;
let mut owned = Handle {
number: issue.number,
child,
stdout: None,
stderr: None,
status: None,
reaped: false,
stop_sent: false,
interrupted: false,
error: None,
completed: false,
#[cfg(test)]
fault: None,
};
if let Err(error) = startup(&mut owned) {
owned.fail(error);
return match owned.wait() {
Err(error) => Err(error),
Ok(_) => Err(anyhow!("interrupted")),
};
}
Ok(owned)
}
fn own_command() -> Option<OsString> {
std::env::args_os().next()
}
pub fn name_this_process() {
#[cfg(target_os = "linux")]
if let Some(own) = own_command()
&& let Some(name) = Path::new(&own).file_name()
&& let Ok(name) = std::ffi::CString::new(name.as_encoded_bytes())
{
unsafe { libc::prctl(libc::PR_SET_NAME, name.as_ptr()) };
}
}
impl Handle {
fn start_readers(&mut self) -> Result<()> {
let number = self.number;
#[cfg(test)]
self.inject_fault(Fault::StdoutPipe)?;
let mut stdout = self
.child
.stdout
.take()
.with_context(|| format!("no stdout from the Run for #{number}"))?;
#[cfg(test)]
self.inject_fault(Fault::StdoutStart)?;
#[cfg(test)]
let fault = self
.fault
.take_if(|fault| matches!(fault, Fault::StdoutRead | Fault::StdoutPanic));
self.stdout = Some(Worker::start(
format!("stdout reader of the Run for #{number}"),
move || {
#[cfg(test)]
if let Some(fault) = fault {
fault
.fire()
.with_context(|| format!("could not read the Run for #{number}"))?;
}
let mut text = String::new();
stdout
.read_to_string(&mut text)
.with_context(|| format!("could not read the Run for #{number}"))?;
Ok(text)
},
)?);
#[cfg(test)]
self.inject_fault(Fault::StderrPipe)?;
let stderr = self
.child
.stderr
.take()
.with_context(|| format!("no stderr from the Run for #{number}"))?;
#[cfg(test)]
self.inject_fault(Fault::StderrStart)?;
#[cfg(test)]
let fault = self
.fault
.take_if(|fault| matches!(fault, Fault::StderrRead | Fault::StderrPanic));
self.stderr = Some(Worker::start(
format!("relay of the Run for #{number}"),
move || {
#[cfg(test)]
if let Some(fault) = fault {
fault
.fire()
.with_context(|| format!("could not read the Run for #{number}"))?;
}
let mut reader = run_ending::Reader::default();
let mut stderr = BufReader::new(stderr);
let mut bytes = Vec::new();
while stderr
.read_until(b'\n', &mut bytes)
.with_context(|| format!("could not read the Run for #{number}"))?
!= 0
{
if bytes.last() == Some(&b'\n') {
bytes.pop();
if bytes.last() == Some(&b'\r') {
bytes.pop();
}
}
let line = String::from_utf8_lossy(&bytes);
reader.read(progress::relay(number, &line));
bytes.clear();
}
Ok(reader)
},
)?);
Ok(())
}
fn try_wait(&mut self) -> Option<Result<Ended>> {
if self.completed || !self.poll_completion() {
return None;
}
self.completed = true;
Some(self.ending())
}
pub fn wait(mut self) -> Result<Ended> {
loop {
if let Some(ended) = self.try_wait() {
return ended;
}
thread::sleep(POLL);
}
}
fn poll_completion(&mut self) -> bool {
if interrupt::requested() && !self.reaped {
self.interrupted = true;
self.stop();
}
if !self.reaped {
#[cfg(test)]
if let Err(error) = self.inject_fault(Fault::Wait) {
self.fail(
error.context(format!("could not wait for the Run for #{}", self.number)),
);
return false;
}
match self.child.try_wait() {
Ok(Some(status)) => {
self.status = Some(status);
self.reaped = true;
}
Ok(None) => {}
Err(error) => {
self.reaped = error.raw_os_error() == Some(libc::ECHILD);
self.fail(
anyhow!(error)
.context(format!("could not wait for the Run for #{}", self.number)),
);
}
}
}
if let Some(worker) = &mut self.stdout
&& let Err(error) = worker.poll()
{
self.fail(error);
}
if let Some(worker) = &mut self.stderr
&& let Err(error) = worker.poll()
{
self.fail(error);
}
self.reaped
&& self.stdout.as_ref().is_none_or(Worker::finished)
&& self.stderr.as_ref().is_none_or(Worker::finished)
}
fn fail(&mut self, error: anyhow::Error) {
self.error.get_or_insert(error);
self.stop();
}
#[cfg(test)]
fn inject_fault(&mut self, at: Fault) -> Result<()> {
if self.fault == Some(at) {
self.fault
.take()
.unwrap()
.fire()
.with_context(|| format!("could not prepare the Run for #{}", self.number))?;
}
Ok(())
}
fn stop(&mut self) {
if !self.reaped && !self.stop_sent {
unsafe { libc::kill(self.child.id() as libc::pid_t, libc::SIGTERM) };
self.stop_sent = true;
}
drop(self.child.stdout.take());
drop(self.child.stderr.take());
}
fn ending(&mut self) -> Result<Ended> {
let local_work = self
.stderr
.as_ref()
.and_then(|worker| worker.value.as_ref())
.map(|reader| reader.local_work().to_vec())
.unwrap_or_default();
if self.interrupted || (self.error.is_some() && interrupt::requested()) {
return Ok(Ended::Interrupted { local_work });
}
if let Some(error) = self.error.take() {
return Err(error);
}
let status = self
.status
.expect("a reaped Run without a wait error has a status");
let stdout = self.stdout.as_mut().unwrap().value.take().unwrap();
let reader = self.stderr.as_mut().unwrap().value.take().unwrap();
let ending = reader.finish(&stdout, status.success());
Ok(match ending.outcome {
Ok(pr_url) => Ended::Reached {
pr_url,
base_fix: ending.base_fix,
},
Err(_) if interrupt::requested() => Ended::Interrupted { local_work },
Err(failure) => Ended::Failed {
cause: failure.cause.unwrap_or_else(|| format!("the Run {status}")),
log: failure.log,
local_work,
},
})
}
}
impl Drop for Handle {
fn drop(&mut self) {
if !self.completed {
self.stop();
while !self.poll_completion() {
thread::sleep(POLL);
}
self.completed = true;
}
}
}
struct Worker<T> {
thread: Option<JoinHandle<Result<T>>>,
value: Option<T>,
name: String,
}
impl<T: Send + 'static> Worker<T> {
fn start(name: String, read: impl FnOnce() -> Result<T> + Send + 'static) -> Result<Self> {
let thread = thread::Builder::new()
.spawn(read)
.with_context(|| format!("could not start the {name}"))?;
Ok(Self {
thread: Some(thread),
value: None,
name,
})
}
}
impl<T> Worker<T> {
fn poll(&mut self) -> Result<()> {
if self.thread.as_ref().is_some_and(JoinHandle::is_finished) {
self.value = Some(self.thread.take().unwrap().join().map_err(|panic| {
let cause = panic
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| panic.downcast_ref::<&str>().copied())
.unwrap_or("unknown panic");
anyhow!("the {} panicked: {cause}", self.name)
})??);
}
Ok(())
}
fn finished(&self) -> bool {
self.thread.is_none()
}
}
#[cfg(test)]
mod execution_tests;
#[cfg(test)]
mod tests {
use super::*;
fn cause_of_not_starting_from(executable: &Path) -> String {
let issue = IssueUrl::parse("https://github.com/acme/widgets/issues/248").unwrap();
let given = Given {
kind: ticket(),
stamp: STAMPED.to_string(),
command: logs::CommandKind::Issue,
base_fix: BaseFixAsk::Forbid,
asks: ChildAsks::default(),
};
let Err(error) = start_from(executable, &issue, &given) else {
panic!("started from {}", executable.display());
};
format!("{error:#}")
}
const URL: &str = "https://github.com/acme/widgets/issues/248";
const STAMPED: &str = "20261003T120000-0400";
fn ticket() -> Kind {
Kind::Ticket {
spec_branch: "issue-237".to_string(),
}
}
fn base_fix() -> Kind {
Kind::BaseFix {
base: "main".to_string(),
}
}
fn read(args: &[&str]) -> Result<Option<Given>> {
let args: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
match crate::args::parse(&args)? {
crate::args::Command::Run(run) => Ok(run.given),
_ => panic!("{args:?}: not a Run"),
}
}
#[test]
fn what_a_child_run_is_given_reads_back_as_written_around_its_issue_url_and_a_flag() {
let offer = BaseFixAsk::Undecided {
retry: format!("thirdshift {URL} --no-merge base-fix"),
};
let on_claude = Choice {
chosen_by: ChosenBy::Command,
..Choice::default()
};
let on_opus = Choice {
model: Some("opus".to_string()),
effort: Some("max".to_string()),
..on_claude.clone()
};
let harnesses = [on_claude, on_opus];
let securities = [(false, false), (true, false), (false, true), (true, true)];
for (kind, harness) in [ticket(), base_fix()]
.into_iter()
.zip(harnesses.iter().cycle())
{
for (base_fix, (security_fix, security_review)) in
[BaseFixAsk::Allow, BaseFixAsk::Forbid, offer.clone()]
.into_iter()
.flat_map(|base_fix| securities.map(|security| (base_fix.clone(), security)))
{
let given = Given {
kind: kind.clone(),
stamp: STAMPED.to_string(),
command: logs::CommandKind::Issue,
base_fix,
asks: ChildAsks {
harness: harness.clone(),
security_fix,
security_review,
},
};
let written = given.to_args();
for at in [0, 2, 4, written.len()] {
let mut args = written.clone();
args.insert(at, URL);
args.insert(at, "no-merge");
assert_eq!(read(&args).unwrap(), Some(given.clone()), "{args:?}");
}
}
}
}
#[test]
fn a_run_given_no_hidden_argument_is_no_child_run() {
assert_eq!(read(&[URL, "base-fix"]).unwrap(), None);
}
const ONLY_A_CHILD_RUN: &str = "only a child Run is given --stamp, --allow-base-fix, \
--offer-base-fix, --sessions-harness, --sessions-model, --sessions-effort or --logs-command";
#[test]
fn hidden_arguments_that_are_repeated_have_no_value_or_lack_a_kind_or_stamp_are_rejected() {
let stamp = ["--stamp", STAMPED];
let ticket = ["--spec-branch", "issue-237"];
for (args, error) in [
(
vec![&ticket[..], &ticket, &stamp],
"repeated argument: --spec-branch",
),
(
vec![&ticket, &["--base-fix-into", "main"], &stamp],
"repeated argument: --base-fix-into",
),
(vec![&ticket, &stamp, &stamp], "repeated argument: --stamp"),
(
vec![&ticket, &stamp, &["--allow-base-fix", "--allow-base-fix"]],
"repeated argument: --allow-base-fix",
),
(
vec![
&ticket,
&stamp,
&["--offer-base-fix", "x", "--offer-base-fix", "y"],
],
"repeated argument: --offer-base-fix",
),
(
vec![
&ticket,
&stamp,
&["--allow-base-fix", "--offer-base-fix", "x"],
],
"--allow-base-fix and --offer-base-fix can't be used together",
),
(
vec![
&ticket,
&stamp,
&["--offer-base-fix", "x", "--allow-base-fix"],
],
"--allow-base-fix and --offer-base-fix can't be used together",
),
(vec![&[URL, "--spec-branch"]], "missing Base branch"),
(vec![&[URL, "--base-fix-into"]], "missing Base branch"),
(vec![&ticket, &[URL, "--stamp"]], "missing stamp"),
(
vec![&ticket, &stamp, &[URL, "--offer-base-fix"]],
"missing command to offer",
),
(vec![&ticket, &[URL]], "missing --stamp"),
(vec![&ticket, &stamp], "missing --sessions-harness"),
(
vec![&ticket, &stamp, &["--sessions-harness", "gemini"]],
"--sessions-harness must be followed by a Harness, not gemini",
),
(
vec![
&ticket,
&stamp,
&["--sessions-model", "opus", "--sessions-model", "opus"],
],
"repeated argument: --sessions-model",
),
(vec![&[URL, "--sessions-effort", "max"]], ONLY_A_CHILD_RUN),
(vec![&["--base-fix-into", "main", URL]], "missing --stamp"),
(vec![&stamp, &[URL]], ONLY_A_CHILD_RUN),
(vec![&[URL, "--allow-base-fix"]], ONLY_A_CHILD_RUN),
(vec![&[URL, "--offer-base-fix", "x"]], ONLY_A_CHILD_RUN),
] {
let mut args: Vec<&str> = args.concat();
if !args.contains(&URL) {
args.push(URL);
}
let rejection = match read(&args) {
Ok(given) => panic!("{args:?}: not rejected, read {given:?}"),
Err(error) => format!("{error:#}"),
};
assert_eq!(rejection, error, "{args:?}");
}
}
#[test]
fn a_child_run_that_cannot_be_started_names_the_executable_that_was_tried() {
let cause = cause_of_not_starting_from(Path::new("/no/such/thirdshift"));
assert!(
cause.starts_with("could not start the Run for #248 from /no/such/thirdshift: "),
"{cause}"
);
}
#[test]
fn a_child_run_that_cannot_be_started_from_a_link_names_the_file_it_leads_to() {
let dir = tempfile::tempdir().unwrap();
let link = dir.path().join("exe");
std::os::unix::fs::symlink("/no/such/thirdshift (deleted)", &link).unwrap();
let cause = cause_of_not_starting_from(&link);
assert!(
cause.starts_with(&format!(
"could not start the Run for #248 from {} (/no/such/thirdshift (deleted)): ",
link.display()
)),
"{cause}"
);
}
}