use argonauts_core::TaskId;
use argonauts_core::events::session::{EndReason, InputPart, SessionEnd};
use argonauts_core::events::task::TryCause;
use argonauts_core::limits::FallbackNote;
use argonauts_core::names::{ModelRef, RoleName, SessionKey};
use argonauts_core::session_runner::Access;
use argonauts_core::steps::task::{Build, TaskReport};
use argonauts_core::steps::{StepName, last_json_object, output_schema};
use crate::engine::host::{Fallbacks, Host, Ran, SessionPlan};
use crate::error::EngineResult;
use crate::task::worktree::{self, TaskTree};
use crate::task::{at_start, prompt};
use crate::workflow::StepAt;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Next {
Fresh {
cause: TryCause,
why: String,
note: Option<Box<FallbackNote>>,
extra: Vec<InputPart>,
},
Resume {
cause: TryCause,
parts: Vec<InputPart>,
},
}
impl Next {
pub(crate) fn fresh(cause: TryCause, why: impl Into<String>) -> Self {
Self::Fresh {
cause,
why: why.into(),
note: None,
extra: Vec::new(),
}
}
pub(crate) fn resume(cause: TryCause, parts: Vec<InputPart>) -> Self {
Self::Resume { cause, parts }
}
pub(crate) fn told(&self) -> &[InputPart] {
match self {
Self::Fresh { .. } => &[],
Self::Resume { parts, .. } => parts,
}
}
pub(crate) fn cause(&self) -> TryCause {
match self {
Self::Fresh { cause, .. } | Self::Resume { cause, .. } => *cause,
}
}
}
pub(crate) fn same_session(cause: TryCause) -> bool {
matches!(
cause,
TryCause::CheckRetry
| TryCause::ReviewChanges
| TryCause::GoalRetry
| TryCause::MergeFailed
| TryCause::Resumed
)
}
#[derive(Debug, Clone)]
pub(crate) struct Ended {
pub key: SessionKey,
pub end: SessionEnd,
pub report: Option<TaskReport>,
}
pub(crate) struct SessionCall<'a> {
pub task: &'a TaskId,
pub worker: &'a ModelRef,
pub tree: &'a TaskTree,
pub at: &'a StepAt,
pub next: Next,
pub resume: Option<SessionKey>,
pub slot: Option<u32>,
}
pub(crate) async fn run(host: &Host, call: SessionCall<'_>) -> EngineResult<Ended> {
let SessionCall {
task,
worker,
tree,
at,
next,
resume,
slot,
} = call;
let starts_over = next.cause() == TryCause::Restarted;
let (parts, resume) = match next {
Next::Fresh {
cause,
why,
note,
extra,
} => {
at_start::ensure(host).await?;
let mut parts = prompt::worker_parts(host, task)?;
if cause != TryCause::First {
let note = note.map(|p| *p);
let told = prompt::note_part(host, task, worker, cause, &why, note).await?;
parts.extend(told);
}
parts.extend(extra);
(parts, None)
}
Next::Resume { parts, .. } => (parts, resume),
};
let mut plan = SessionPlan {
at: Some(at.clone()),
role: RoleName::Worker,
worker: worker.clone(),
task: Some(task.clone()),
access: Access::Build,
step: Some(StepName::Build),
workdir: tree.path.clone(),
parts,
json_schema: Some(output_schema::<Build>()),
resume,
slot: 0,
label: None,
reviews: false,
fresh: starts_over,
independent_of: Vec::new(),
in_place_of: None,
fork_of: None,
};
plan.slot = slot.unwrap_or_else(|| host.slot_for(&plan));
let Ran {
key,
worker: model,
end,
} = host.run_session(plan, Fallbacks::None).await?;
worktree::commit_in(host, task, tree, Some(&key), Some(model)).await?;
let report = report_of(&end);
Ok(Ended { key, end, report })
}
pub(crate) fn report_of(end: &SessionEnd) -> Option<TaskReport> {
if end.reason != EndReason::Completed {
return None;
}
let mut value = end
.structured
.clone()
.filter(serde_json::Value::is_object)
.or_else(|| {
[&end.result_text, &end.last_message]
.into_iter()
.flatten()
.find_map(|text| last_json_object(text))
})?;
if let Some(report) = value.as_object_mut() {
for list in [
"files_changed",
"tests_run",
"concerns",
"questions",
"change_requests",
"memory_proposals",
] {
if report.get(list).is_none_or(serde_json::Value::is_null) {
report.insert(list.to_owned(), serde_json::Value::Array(Vec::new()));
}
}
if report.get("answer").is_some_and(serde_json::Value::is_null) {
report.remove("answer");
}
}
serde_json::from_value(value).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use argonauts_core::Spend;
use serde_json::json;
fn end(reason: EndReason, structured: Option<serde_json::Value>) -> SessionEnd {
SessionEnd {
cli_session: Some("v-1".into()),
exit_code: Some(0),
signal: None,
duration_ms: 10,
reason,
spend: Spend::default(),
tool_calls: 3,
result_text: None,
structured,
last_message: None,
raw_log: None,
}
}
fn report() -> serde_json::Value {
json!({
"status": "done", "summary": "built it", "files_changed": ["src/a.rs"],
"tests_run": [], "concerns": [], "questions": [], "change_requests": [],
"memory_proposals": []
})
}
#[test]
fn only_a_completed_session_with_a_valid_report_has_one() {
let done = report_of(&end(EndReason::Completed, Some(report()))).unwrap();
assert_eq!(done.summary, "built it");
assert!(report_of(&end(EndReason::Completed, None)).is_none());
assert!(report_of(&end(EndReason::Completed, Some(json!({"status": "done"})))).is_none());
assert!(report_of(&end(EndReason::Stopped, Some(report()))).is_none());
let first_shape = json!({
"status": "done", "summary": "built it", "files_changed": [], "tests_run": [],
"concerns": []
});
let done = report_of(&end(EndReason::Completed, Some(first_shape.clone()))).unwrap();
assert!(done.questions.is_empty() && done.memory_proposals.is_empty());
let mut in_text = end(EndReason::Completed, None);
in_text.result_text = Some(format!("Built it.\n\n```json\n{first_shape}\n```"));
assert_eq!(report_of(&in_text).unwrap().summary, "built it");
let with_nulls = json!({
"status": "done", "summary": "fixed slugify", "files_changed": ["slug.py"],
"tests_run": ["python3 -m unittest -q (OK)"], "concerns": [], "questions": null,
"change_requests": null, "memory_proposals": null, "answer": null
});
let mut qwen = end(EndReason::Completed, None);
qwen.result_text = Some(format!("## Report\n\n```json\n{with_nulls:#}\n```"));
let report = report_of(&qwen).expect("the report");
assert_eq!(report.files_changed, ["slug.py"]);
assert!(report.questions.is_empty() && report.change_requests.is_empty());
}
#[test]
fn retries_and_resumes_keep_the_session() {
for cause in [
TryCause::CheckRetry,
TryCause::ReviewChanges,
TryCause::MergeFailed,
TryCause::Resumed,
] {
assert!(same_session(cause), "{cause:?}");
}
for cause in [
TryCause::First,
TryCause::AfterFailure,
TryCause::Fallback,
TryCause::Reassigned,
TryCause::Restarted,
TryCause::Rotation,
] {
assert!(!same_session(cause), "{cause:?}");
}
}
}