use std::cell::{OnceCell, RefCell};
#[cfg(test)]
use std::fs::{self, File};
#[cfg(test)]
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(test)]
use anyhow::bail;
use anyhow::{Context, Result, anyhow};
#[cfg(test)]
pub(crate) use crate::harness::claude::claude_args;
#[cfg(test)]
pub(crate) use crate::harness::codex::{codex_args, codex_prompt};
use crate::harness::interpretation::Ended;
use crate::harness::{Adapter, Choice};
use crate::issue::{IssueUrl, Repo};
use crate::logs;
use crate::progress;
use crate::prompt;
use crate::skills;
pub mod recording;
mod review_reports;
use review_reports::ReviewReports;
#[cfg(test)]
mod execution_tests;
#[derive(Clone, Copy)]
pub enum Purpose {
Ordinary,
CodeReview,
Security,
SecurityPublishing,
SecurityReview,
}
pub struct Logs {
name: String,
dir: PathBuf,
recording: OnceCell<recording::Recording>,
}
impl Logs {
pub fn of_run(issue: &IssueUrl) -> Self {
Logs {
name: issue.number.to_string(),
dir: logs::session_dir(&issue.repo()),
recording: OnceCell::new(),
}
}
pub fn of_architect_run(repo: &Repo) -> Self {
Logs {
name: "architect".to_string(),
dir: logs::session_dir(repo),
recording: OnceCell::new(),
}
}
pub fn of_security_run(repo: &Repo) -> Self {
Logs {
name: "secure".to_string(),
dir: logs::session_dir(repo),
recording: OnceCell::new(),
}
}
pub fn path(&self, kind: &str) -> Result<PathBuf> {
self.recording().session_path(&self.dir, &self.name, kind)
}
fn recording(&self) -> &recording::Recording {
self.recording.get_or_init(recording::Recording::capture)
}
}
pub struct Sessions<'a> {
logs: &'a Logs,
worktree: &'a Path,
outside: RefCell<Box<dyn Outside>>,
last_log: RefCell<Option<PathBuf>>,
endings_with_killed_work: RefCell<Vec<String>>,
}
impl<'a> Sessions<'a> {
pub fn within<T>(
logs: &'a Logs,
worktree: &'a Path,
harness: &'a Choice,
steps: impl FnOnce(&Self) -> Result<T>,
) -> (Result<T>, Option<PathBuf>) {
let adapter = harness.harness.adapter();
if let Err(error) = skills::link_into(worktree, adapter)
.and_then(|()| adapter.link_instruction_fallback(worktree))
{
return (Err(error), None);
}
let on_machine = OnMachine {
worktree: worktree.to_path_buf(),
harness: harness.clone(),
adapter,
recording: logs.recording().clone(),
};
let (taken, log) = Sessions::taking(logs, worktree, Box::new(on_machine), steps);
(taken, log.filter(|log| log.exists()))
}
fn taking<T>(
logs: &'a Logs,
worktree: &'a Path,
outside: Box<dyn Outside>,
steps: impl FnOnce(&Self) -> Result<T>,
) -> (Result<T>, Option<PathBuf>) {
let sessions = Sessions {
logs,
worktree,
outside: RefCell::new(outside),
last_log: RefCell::default(),
endings_with_killed_work: RefCell::default(),
};
let taken = steps(&sessions);
let log = sessions.last_log.into_inner();
let endings = sessions.endings_with_killed_work.into_inner();
if endings.is_empty() {
return (taken, log);
}
let taken = taken.map_err(|error| {
error.context(format!("{}, and a later step failed", endings.join("; ")))
});
(taken, log)
}
pub fn run(&self, kind: &str, prompt: &str) -> Result<()> {
let purpose = if prompt.contains(prompt::REVIEW_REPORTS_DIRECTORY) {
Purpose::CodeReview
} else {
Purpose::Ordinary
};
self.run_to_final_message(purpose, kind, prompt).map(drop)
}
pub fn run_to_final_message(
&self,
purpose: Purpose,
kind: &str,
prompt: &str,
) -> Result<Option<String>> {
let reports = if prompt.contains(prompt::REVIEW_REPORTS_DIRECTORY) {
match ReviewReports::new(self.worktree) {
Ok(reports) => Some(reports),
Err(error) => {
self.step(format!(
"{kind}: could not prepare review reports: {error:#}; carrying on"
));
None
}
}
} else {
None
};
let mut ended = self.start(purpose, kind, None, prompt, reports.as_ref())?;
let mut ended_last = "session";
if let (false, Some(session_id)) = (ended.killed.is_empty(), &ended.session_id) {
let mut resume_prompt = prompt::resume(&ended.killed_work());
if reports.is_some() {
resume_prompt.push_str(prompt::REVIEW_REPORTS);
}
self.step(format!(
"{kind}: background work was killed as the session ended; resuming it once"
));
ended = self.start(
purpose,
&format!("{kind}-resume"),
Some(session_id),
&resume_prompt,
reports.as_ref(),
)?;
ended_last = "Resume";
}
if !ended.killed.is_empty() {
let ending = ending_with(&ended.killed_work());
if matches!(
purpose,
Purpose::Security | Purpose::SecurityPublishing | Purpose::SecurityReview
) {
return Err(anyhow!(
"{kind}: the {ended_last} {ending}; Security session incomplete"
));
}
self.step(format!(
"{kind}: the {ended_last} {ending}; carrying on, as it may have been abandoned"
));
self.endings_with_killed_work
.borrow_mut()
.push(format!("{kind} session {ending}"));
}
Ok(ended.final_message)
}
fn start(
&self,
purpose: Purpose,
kind: &str,
resume: Option<&str>,
prompt: &str,
reports: Option<&ReviewReports>,
) -> Result<Ended> {
let log = self.logs.path(kind)?;
logs::session(kind, &log);
self.step(format!("logging the session to {}", log.display()));
*self.last_log.borrow_mut() = Some(log.clone());
let prompt = reports.map_or_else(
|| prompt.replace(prompt::REVIEW_REPORTS, ""),
|reports| reports.prompt(prompt),
);
let ended = self
.outside
.borrow_mut()
.run_session(purpose, kind, resume, &prompt, &log);
if let Some(reports) = reports {
for line in reports.keep(&log) {
self.step(format!("{kind}: {line}"));
}
}
ended
}
fn step(&self, line: String) {
self.outside.borrow_mut().step(line);
}
}
trait Outside {
fn run_session(
&mut self,
purpose: Purpose,
kind: &str,
resume: Option<&str>,
prompt: &str,
log: &Path,
) -> Result<Ended>;
fn step(&mut self, line: String);
}
struct OnMachine {
worktree: PathBuf,
harness: Choice,
adapter: &'static dyn Adapter,
recording: recording::Recording,
}
impl Outside for OnMachine {
fn run_session(
&mut self,
purpose: Purpose,
kind: &str,
resume: Option<&str>,
prompt: &str,
log: &Path,
) -> Result<Ended> {
let interpretation = self.adapter.interpretation(&self.worktree, prompt);
let (invocation, interpretation) = match purpose {
Purpose::Security | Purpose::SecurityPublishing | Purpose::SecurityReview => (
self.adapter.security_session(
&self.harness,
resume,
prompt,
matches!(purpose, Purpose::Security | Purpose::SecurityPublishing),
),
interpretation.for_security(self.harness.model.as_deref()),
),
Purpose::Ordinary => (
self.adapter.session(&self.harness, resume, prompt),
interpretation,
),
Purpose::CodeReview => (
self.adapter
.code_review_session(&self.harness, resume, prompt),
interpretation,
),
};
let mut command = Command::new(self.adapter.name());
command.args(&invocation.args).current_dir(&self.worktree);
let github_config = if matches!(purpose, Purpose::SecurityPublishing) {
let config = tempfile::Builder::new()
.prefix("thirdshift-gh-")
.tempdir()
.context("could not prepare an unauthenticated GitHub config")?;
for name in [
"GH_TOKEN",
"GITHUB_TOKEN",
"GH_ENTERPRISE_TOKEN",
"GITHUB_ENTERPRISE_TOKEN",
] {
command.env_remove(name);
}
command
.env("GH_CONFIG_DIR", config.path())
.env("GH_PROMPT_DISABLED", "1");
Some(config)
} else {
None
};
let ended = self.recording.run(
kind,
&self.harness,
&mut command,
invocation.stdin.as_deref(),
log,
interpretation,
);
drop(github_config);
ended
}
fn step(&mut self, line: String) {
progress::step(line);
}
}
fn ending_with(killed: &[&str]) -> String {
match killed {
[task] => format!("ended with a background task still running ({task}), which was killed"),
_ => format!(
"ended with background tasks still running ({}), which were killed",
killed.join("; ")
),
}
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::rc::Rc;
use anyhow::anyhow;
use super::*;
#[test]
fn unsafe_project_skills_fail_session_start_before_the_harness_can_run() {
for harness in crate::harness::Harness::ALL {
let worktree = crate::test_support::scratch_worktree();
let outside = tempfile::tempdir().unwrap();
let dir = worktree.path().join(harness.adapter().project_skills());
fs::create_dir_all(dir.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(outside.path(), &dir).unwrap();
let logs = Logs {
name: "670".into(),
dir: worktree.path().join("session-logs"),
recording: OnceCell::new(),
};
let choice = Choice {
harness,
..Choice::default()
};
let (result, log) =
Sessions::within(&logs, worktree.path(), &choice, |_| -> Result<()> {
panic!("unsafe project skills must prevent session steps");
});
let error = result.unwrap_err();
assert!(format!("{error:#}").contains(&dir.display().to_string()));
assert!(log.is_none());
assert!(!logs.dir.exists());
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
}
}
#[derive(Debug, PartialEq)]
enum Call {
Session {
kind: String,
resume: Option<String>,
prompt: String,
log: PathBuf,
},
Step(String),
}
struct Scripted {
endings: VecDeque<Result<Ended>>,
calls: Rc<RefCell<Vec<Call>>>,
}
impl Outside for Scripted {
fn run_session(
&mut self,
_purpose: Purpose,
kind: &str,
resume: Option<&str>,
prompt: &str,
log: &Path,
) -> Result<Ended> {
self.calls.borrow_mut().push(Call::Session {
kind: kind.to_string(),
resume: resume.map(String::from),
prompt: prompt.to_string(),
log: log.to_path_buf(),
});
self.endings
.pop_front()
.expect("a session was run that was not scripted")
}
fn step(&mut self, line: String) {
self.calls.borrow_mut().push(Call::Step(line));
}
}
fn ended(session_id: Option<&str>, killed: &[&str], said: &str) -> Result<Ended> {
Ok(Ended {
session_id: session_id.map(String::from),
killed: killed.iter().map(|work| work.to_string()).collect(),
final_message: Some(said.to_string()),
})
}
fn logs() -> Logs {
Logs {
name: "7".to_string(),
dir: PathBuf::from("/logs/sessions"),
recording: recording::Recording::new(
Some(PathBuf::from("/logs/7-20261003T120000-0400~fixture.log")),
None,
"20261003T120000-0400".into(),
)
.into(),
}
}
#[test]
fn same_second_commands_keep_session_model_and_review_contents_separate() {
let root = crate::test_support::scratch_worktree();
let stamp = "20261003T120000-0400";
let mut captured = Vec::new();
for marker in ["first", "second"] {
let recording = recording::Recording::new(
Some(root.path().join(format!("7-{stamp}~{marker}.log"))),
None,
stamp.into(),
);
let logs = Logs {
name: "7".into(),
dir: root.path().into(),
recording: recording.clone().into(),
};
let log = logs.path("implement").unwrap();
recording.run(
"implement", &Choice::default(),
Command::new("/bin/sh").args(["-c", &format!(
"echo '{{\"type\":\"assistant\",\"message\":{{\"model\":\"{marker}-model\",\"content\":[]}}}}'"
)]), None, &log,
crate::harness::Harness::Claude.adapter().interpretation(root.path(), ""),
).unwrap();
let reports = ReviewReports::new(root.path()).unwrap();
for axis in ["standards", "spec"] {
fs::write(
Path::new(&reports.prompt(prompt::REVIEW_REPORTS_DIRECTORY))
.join(format!("{axis}.md")),
format!("{marker} {axis}"),
)
.unwrap();
}
assert!(reports.keep(&log).is_empty());
captured.push((marker, log, recording));
}
for (marker, log, recording) in captured {
assert!(
fs::read_to_string(&log)
.unwrap()
.contains(&format!("{marker}-model")),
"lost {marker} Session"
);
for axis in ["standards", "spec"] {
assert_eq!(
fs::read_to_string(log.with_extension(axis)).unwrap(),
format!("{marker} {axis}")
);
}
assert_eq!(
recording.notification_lines(),
[format!(
"- implement: claude · {marker}-model (reported) · session effort: default effort"
)]
);
}
}
fn take<T>(
endings: Vec<Result<Ended>>,
steps: impl FnOnce(&Sessions) -> Result<T>,
) -> (Result<T>, Option<PathBuf>, Vec<Call>) {
let calls = Rc::new(RefCell::new(Vec::new()));
let scripted = Scripted {
endings: endings.into(),
calls: Rc::clone(&calls),
};
let logs = logs();
let (taken, log) = Sessions::taking(&logs, Path::new("/unused"), Box::new(scripted), steps);
let calls = calls.take();
(taken, log, calls)
}
fn session(kind: &str, resume: Option<&str>, prompt: &str) -> Call {
Call::Session {
kind: kind.to_string(),
resume: resume.map(String::from),
prompt: prompt.to_string(),
log: logs().path(kind).unwrap(),
}
}
fn logging(kind: &str) -> Call {
Call::Step(format!(
"logging the session to {}",
logs().path(kind).unwrap().display()
))
}
fn sessions_run(calls: &[Call]) -> Vec<&Call> {
calls
.iter()
.filter(|call| matches!(call, Call::Session { .. }))
.collect()
}
const TESTS_KILLED: &str =
"ended with a background task still running (cargo test), which was killed";
#[test]
fn a_clean_session_runs_once_and_returns_its_final_message() {
let (taken, log, calls) = take(vec![ended(Some("s1"), &[], "done")], |sessions| {
sessions.run_to_final_message(Purpose::Ordinary, "implement", "do it")
});
assert_eq!(taken.unwrap(), Some("done".to_string()));
assert_eq!(
calls,
[logging("implement"), session("implement", None, "do it")]
);
assert_eq!(log, Some(logs().path("implement").unwrap()));
}
#[test]
fn a_session_whose_background_work_was_killed_is_resumed_once_on_its_id() {
let (taken, log, calls) = take(
vec![
ended(Some("s1"), &["cargo test"], "waiting"),
ended(Some("s1"), &[], "done"),
],
|sessions| sessions.run_to_final_message(Purpose::Ordinary, "implement", "do it"),
);
assert_eq!(taken.unwrap(), Some("done".to_string()));
assert_eq!(
calls,
[
logging("implement"),
session("implement", None, "do it"),
Call::Step(
"implement: background work was killed as the session ended; resuming it once"
.to_string()
),
logging("implement-resume"),
session(
"implement-resume",
Some("s1"),
&prompt::resume(&["cargo test"])
),
]
);
assert_eq!(log, Some(logs().path("implement-resume").unwrap()));
}
#[test]
fn a_security_session_cannot_claim_completion_with_killed_work() {
for id in [None, Some("s1")] {
let mut endings = vec![ended(
id,
&["cargo test"],
"Security review: {\"unaddressed_count\":0,\"findings\":[]}",
)];
if id.is_some() {
endings.push(ended(
id,
&["cargo test"],
"Security review: {\"unaddressed_count\":0,\"findings\":[]}",
));
}
let (taken, _, _) = take(endings, |sessions| {
sessions.run_to_final_message(Purpose::Security, "security-review", "review")
});
assert!(
taken.is_err(),
"unfinished security work must hold Self-merge"
);
}
}
#[test]
fn a_resume_that_ends_the_same_way_gets_no_second_and_a_later_failure_names_the_work() {
let (taken, log, calls) = take(
vec![
ended(Some("s1"), &["cargo test"], "waiting"),
ended(Some("s1"), &["cargo test"], "still waiting"),
],
|sessions| -> Result<()> {
assert_eq!(
sessions
.run_to_final_message(Purpose::Ordinary, "implement", "do it")
.unwrap(),
Some("still waiting".to_string())
);
bail!("no PR found")
},
);
assert_eq!(sessions_run(&calls).len(), 2, "one Resume, never two");
assert_eq!(
calls.last(),
Some(&Call::Step(format!(
"implement: the Resume {TESTS_KILLED}; carrying on, as it may have been abandoned"
)))
);
assert_eq!(
format!("{:#}", taken.unwrap_err()),
format!("implement session {TESTS_KILLED}, and a later step failed: no PR found")
);
assert_eq!(log, Some(logs().path("implement-resume").unwrap()));
}
#[test]
fn a_session_with_no_id_to_resume_is_not_resumed_and_carries_on() {
let (taken, log, calls) = take(
vec![ended(None, &["cargo test", "npm test"], "waiting")],
|sessions| sessions.run("implement", "do it"),
);
taken.unwrap();
assert_eq!(
calls,
[
logging("implement"),
session("implement", None, "do it"),
Call::Step(
"implement: the session ended with background tasks still running \
(cargo test; npm test), which were killed; carrying on, as it may have \
been abandoned"
.to_string()
),
]
);
assert_eq!(log, Some(logs().path("implement").unwrap()));
}
#[test]
fn a_failing_session_fails_unchanged_without_a_resume_or_an_ending() {
let (taken, log, calls) = take(vec![Err(anyhow!("claude exited 3"))], |sessions| {
let failed = sessions.run("implement", "do it").unwrap_err();
assert_eq!(format!("{failed:#}"), "claude exited 3");
Err::<(), _>(failed)
});
assert_eq!(format!("{:#}", taken.unwrap_err()), "claude exited 3");
assert_eq!(
calls,
[logging("implement"), session("implement", None, "do it")]
);
assert_eq!(log, Some(logs().path("implement").unwrap()));
}
#[test]
fn steps_that_succeed_after_killed_work_come_to_what_they_came_to() {
let (taken, _, _) = take(vec![ended(None, &["cargo test"], "waiting")], |sessions| {
sessions.run("implement", "do it")?;
Ok(42)
});
assert_eq!(taken.unwrap(), 42);
}
#[test]
fn every_session_that_left_killed_work_is_named_oldest_first() {
let (taken, _, calls) = take(
vec![
ended(None, &["cargo test"], "waiting"),
ended(None, &["npm test"], "waiting"),
],
|sessions| -> Result<()> {
sessions.run("implement", "do it")?;
sessions.run("repair-1", "fix it")?;
bail!("CI failed")
},
);
assert_eq!(sessions_run(&calls).len(), 2);
assert_eq!(
format!("{:#}", taken.unwrap_err()),
format!(
"implement session {TESTS_KILLED}; repair-1 session ended with a background \
task still running (npm test), which was killed, and a later step failed: \
CI failed"
)
);
}
#[test]
fn a_prompt_that_loads_a_skill_loads_it_with_codexs_sigil_on_codex() {
let issue = IssueUrl::parse("https://github.com/acme/widgets/issues/7").unwrap();
for (prompt, first_line) in [
(
prompt::fresh(&issue, "main", "issue-7"),
"$thirdshift-implement https://github.com/acme/widgets/issues/7",
),
(
prompt::conflict_repair(&issue, "main", "issue-7", "<pr>"),
"$thirdshift-resolving-merge-conflicts",
),
(
prompt::review_repair(&issue, "issue-7", "<pr>", "abc123"),
"$thirdshift-code-review abc123",
),
(
prompt::resume(&["cargo test"]),
"Your background work (cargo test) was killed when your turn ended, because \
ending the turn ends the session.",
),
] {
let codex_prompt = codex_prompt(&prompt);
assert_eq!(codex_prompt.lines().next(), Some(first_line));
assert_eq!(codex_prompt[1..], prompt[1..]);
}
}
}