Skip to content

Commit 8439357

Browse files
committed
Accept canonical executor evidence during cook publication
1 parent 9a701ee commit 8439357

3 files changed

Lines changed: 283 additions & 1 deletion

File tree

src/core/agent_task_finalization.rs

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,17 @@ mod tests {
836836
ExternalRuntimeId, FinalizationLifecycle, FinalizationState, ProviderRuntimeLifecycle,
837837
ProviderRuntimeState, RunExecutionLifecycle, RunExecutionState,
838838
};
839+
use crate::core::{
840+
agent_task::{
841+
AgentTaskArtifact, AgentTaskOutcome, AgentTaskOutcomeStatus, AgentTaskRequest,
842+
AGENT_TASK_ARTIFACT_SCHEMA, AGENT_TASK_OUTCOME_SCHEMA,
843+
},
844+
agent_task_scheduler::{
845+
AgentTaskAggregate, AgentTaskAggregateStatus, AgentTaskAggregateTotals, AgentTaskPlan,
846+
AgentTaskProgressEvent, AgentTaskQueueStatus, AgentTaskState,
847+
AGENT_TASK_AGGREGATE_SCHEMA,
848+
},
849+
};
839850
use std::process::Command;
840851

841852
#[derive(Default)]
@@ -851,6 +862,7 @@ mod tests {
851862
committed: bool,
852863
pushed: bool,
853864
created: bool,
865+
create_calls: u8,
854866
updated: bool,
855867
last_body: String,
856868
}
@@ -949,6 +961,7 @@ mod tests {
949961
return Err(Error::git_command_failed("gh pr create failed"));
950962
}
951963
self.created = true;
964+
self.create_calls += 1;
952965
self.last_body = body.to_string();
953966
Ok(AgentTaskPrRef {
954967
number: 123,
@@ -1320,6 +1333,145 @@ mod tests {
13201333
.is_err());
13211334
}
13221335

1336+
#[test]
1337+
fn durable_finalization_accepts_succeeded_generic_executor_outcome_once() {
1338+
let mut backend = MockBackend {
1339+
changed_files: vec!["src/lib.rs".to_string()],
1340+
lifecycle: Some(generic_executor_lifecycle(
1341+
ProviderRuntimeState::Succeeded,
1342+
"openai/gpt-5.6-terra",
1343+
)),
1344+
gate_proof: Some(successful_gate_proof()),
1345+
..Default::default()
1346+
};
1347+
let mut finalization_options = options();
1348+
finalization_options.manual_finalization = false;
1349+
1350+
let report = finalize_pr_with_backend(finalization_options, &mut backend)
1351+
.expect("succeeded generic executor outcome finalizes");
1352+
1353+
assert_eq!(report.pr_action, "created");
1354+
assert!(backend.committed && backend.pushed && backend.created);
1355+
assert_eq!(backend.create_calls, 1);
1356+
assert_eq!(
1357+
report.evidence.lifecycle.as_ref().unwrap().provider_runtime[0].metadata
1358+
["evidence_source"],
1359+
"canonical_executor_outcome"
1360+
);
1361+
assert!(
1362+
report.evidence.lifecycle.as_ref().unwrap().provider_runtime[0]
1363+
.external_runtime_ids
1364+
.is_empty()
1365+
);
1366+
1367+
for rejected_state in [ProviderRuntimeState::Failed, ProviderRuntimeState::TimedOut] {
1368+
let mut rejected_backend = MockBackend {
1369+
changed_files: vec!["src/lib.rs".to_string()],
1370+
lifecycle: Some(generic_executor_lifecycle(
1371+
rejected_state,
1372+
"openai/gpt-5.6-terra",
1373+
)),
1374+
gate_proof: Some(successful_gate_proof()),
1375+
..Default::default()
1376+
};
1377+
let mut rejected_options = options();
1378+
rejected_options.manual_finalization = false;
1379+
1380+
assert!(finalize_pr_with_backend(rejected_options, &mut rejected_backend).is_err());
1381+
assert!(!rejected_backend.committed);
1382+
}
1383+
}
1384+
1385+
#[test]
1386+
fn durable_finalization_accepts_native_and_generic_evidence_but_omits_skipped_work() {
1387+
crate::test_support::with_isolated_home(|_| {
1388+
let plan = AgentTaskPlan::new(
1389+
"mixed-executor-plan",
1390+
vec![
1391+
durable_task("native", "native-executor", Some("native-model")),
1392+
durable_task("generic", "opencode", Some("openai/gpt-5.6-terra")),
1393+
durable_task("skipped", "opencode", None),
1394+
],
1395+
);
1396+
let aggregate = AgentTaskAggregate {
1397+
schema: AGENT_TASK_AGGREGATE_SCHEMA.to_string(),
1398+
plan_id: plan.plan_id.clone(),
1399+
status: AgentTaskAggregateStatus::Succeeded,
1400+
totals: AgentTaskAggregateTotals {
1401+
succeeded: 2,
1402+
skipped: 1,
1403+
..Default::default()
1404+
},
1405+
outcomes: vec![
1406+
durable_succeeded_outcome(
1407+
"native",
1408+
json!({
1409+
"provider": "native-executor",
1410+
"provider_run_id": "native-run-123",
1411+
"model": "native-model",
1412+
}),
1413+
),
1414+
durable_succeeded_outcome("generic", serde_json::Value::Null),
1415+
],
1416+
events: vec![AgentTaskProgressEvent {
1417+
task_id: "skipped".to_string(),
1418+
state: AgentTaskState::Skipped,
1419+
attempt: 1,
1420+
message: Some("not applicable".to_string()),
1421+
}],
1422+
artifact_lineage: Vec::new(),
1423+
child_runs: Vec::new(),
1424+
artifact_bindings: Vec::new(),
1425+
queue: AgentTaskQueueStatus::default(),
1426+
};
1427+
let record = crate::core::agent_task_lifecycle::record_completed_run(
1428+
&plan,
1429+
&aggregate,
1430+
Some("cook-3678"),
1431+
)
1432+
.expect("durable aggregate recorded");
1433+
let runtimes = &record.lifecycle.provider_runtime;
1434+
1435+
assert_eq!(
1436+
record.state,
1437+
crate::core::agent_task_lifecycle::AgentTaskRunState::Succeeded
1438+
);
1439+
assert_eq!(runtimes.len(), 2);
1440+
assert_eq!(runtimes[0].external_runtime_ids[0].value, "native-run-123");
1441+
assert_eq!(runtimes[1].backend, "opencode");
1442+
assert_eq!(
1443+
runtimes[1].metadata["evidence_source"],
1444+
"canonical_executor_outcome"
1445+
);
1446+
assert!(runtimes.iter().all(|runtime| runtime.task_id != "skipped"));
1447+
assert_eq!(record.artifact_refs[0].kind, "patch");
1448+
1449+
let mut backend = MockBackend {
1450+
changed_files: vec!["src/lib.rs".to_string()],
1451+
lifecycle: Some(record.lifecycle),
1452+
gate_proof: Some(successful_gate_proof()),
1453+
..Default::default()
1454+
};
1455+
let mut finalization_options = options();
1456+
finalization_options.manual_finalization = false;
1457+
1458+
let report = finalize_pr_with_backend(finalization_options.clone(), &mut backend)
1459+
.expect("mixed durable evidence finalizes");
1460+
assert_eq!(report.pr_action, "created");
1461+
assert_eq!(backend.create_calls, 1);
1462+
1463+
backend.existing_pr = Some(AgentTaskPrRef {
1464+
number: 123,
1465+
url: "https://github.com/Extra-Chill/homeboy/pull/123".to_string(),
1466+
});
1467+
let repeated = finalize_pr_with_backend(finalization_options, &mut backend)
1468+
.expect("existing PR is reused");
1469+
assert_eq!(repeated.pr_action, "updated");
1470+
assert_eq!(backend.create_calls, 1);
1471+
assert!(backend.updated);
1472+
});
1473+
}
1474+
13231475
fn real_git_finalization_options(
13241476
path: &std::path::Path,
13251477
changed_files: Vec<String>,
@@ -1511,6 +1663,71 @@ mod tests {
15111663
}
15121664
}
15131665

1666+
fn generic_executor_lifecycle(state: ProviderRuntimeState, model: &str) -> RunLifecycleRecord {
1667+
RunLifecycleRecord {
1668+
execution: RunExecutionLifecycle {
1669+
state: RunExecutionState::Succeeded,
1670+
started_at: None,
1671+
finished_at: Some("2026-01-01T00:00:00Z".to_string()),
1672+
updated_at: None,
1673+
},
1674+
provider_runtime: vec![ProviderRuntimeLifecycle {
1675+
task_id: "task".to_string(),
1676+
backend: "opencode".to_string(),
1677+
state,
1678+
stream_uri: None,
1679+
external_runtime_ids: Vec::new(),
1680+
metadata: json!({
1681+
"evidence_source": "canonical_executor_outcome",
1682+
"executor": { "backend": "opencode", "model": model },
1683+
"model": model,
1684+
}),
1685+
}],
1686+
..RunLifecycleRecord::default()
1687+
}
1688+
}
1689+
1690+
fn durable_task(task_id: &str, backend: &str, model: Option<&str>) -> AgentTaskRequest {
1691+
serde_json::from_value(json!({
1692+
"task_id": task_id,
1693+
"executor": { "backend": backend, "model": model },
1694+
"instructions": "run",
1695+
}))
1696+
.expect("durable task")
1697+
}
1698+
1699+
fn durable_succeeded_outcome(task_id: &str, metadata: serde_json::Value) -> AgentTaskOutcome {
1700+
AgentTaskOutcome {
1701+
schema: AGENT_TASK_OUTCOME_SCHEMA.to_string(),
1702+
task_id: task_id.to_string(),
1703+
status: AgentTaskOutcomeStatus::Succeeded,
1704+
summary: Some("succeeded".to_string()),
1705+
failure_classification: None,
1706+
artifacts: vec![AgentTaskArtifact {
1707+
schema: AGENT_TASK_ARTIFACT_SCHEMA.to_string(),
1708+
id: format!("{task_id}-patch"),
1709+
kind: "patch".to_string(),
1710+
name: None,
1711+
label: None,
1712+
role: None,
1713+
semantic_key: None,
1714+
path: Some(format!("/tmp/{task_id}.patch")),
1715+
url: None,
1716+
mime: None,
1717+
size_bytes: None,
1718+
sha256: None,
1719+
metadata: serde_json::Value::Null,
1720+
}],
1721+
typed_artifacts: Vec::new(),
1722+
evidence_refs: Vec::new(),
1723+
diagnostics: Vec::new(),
1724+
outputs: serde_json::Value::Null,
1725+
workflow: None,
1726+
follow_up: None,
1727+
metadata,
1728+
}
1729+
}
1730+
15141731
fn successful_gate_proof() -> AgentTaskPrDurableGateProof {
15151732
AgentTaskPrDurableGateProof {
15161733
run_id: "cook-3678".to_string(),

src/core/agent_task_lifecycle/lifecycle_record_ops.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,44 @@ pub(crate) fn update_lifecycle_heartbeat(record: &mut AgentTaskRunRecord) {
5151
pub(crate) fn update_lifecycle_from_record(record: &mut AgentTaskRunRecord, plan: &AgentTaskPlan) {
5252
set_run_state(record, record.state);
5353
record.lifecycle.cleanup = cleanup_lifecycle_for_plan(plan, record.updated_at.clone());
54-
record.lifecycle.provider_runtime = record
54+
let mut provider_runtime: Vec<ProviderRuntimeLifecycle> = record
5555
.provider_handles
5656
.iter()
5757
.map(provider_runtime_for_handle)
5858
.collect();
59+
for task in &record.tasks {
60+
if record
61+
.provider_handles
62+
.iter()
63+
.any(|handle| handle.task_id == task.task_id)
64+
|| matches!(
65+
task.state,
66+
AgentTaskState::Queued | AgentTaskState::Blocked | AgentTaskState::Skipped
67+
)
68+
{
69+
continue;
70+
}
71+
// A completed generic executor may only produce the canonical aggregate
72+
// outcome, without a provider-native run id. Preserve its terminal
73+
// evidence rather than treating the missing external id as no execution.
74+
provider_runtime.push(ProviderRuntimeLifecycle {
75+
task_id: task.task_id.clone(),
76+
backend: task.backend.clone(),
77+
state: provider_runtime_state_for_task_state(Some(task.state)),
78+
stream_uri: None,
79+
external_runtime_ids: Vec::new(),
80+
metadata: json!({
81+
"evidence_source": "canonical_executor_outcome",
82+
"executor": {
83+
"backend": task.backend,
84+
"selector": task.selector,
85+
"model": task.model,
86+
},
87+
"model": task.model,
88+
}),
89+
});
90+
}
91+
record.lifecycle.provider_runtime = provider_runtime;
5992
record.lifecycle.external_runtime_ids = record
6093
.lifecycle
6194
.provider_runtime

src/core/agent_task_lifecycle/tests.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1937,6 +1937,38 @@ fn completed_run_exposes_latest_executor_input_output_and_expectations() {
19371937
});
19381938
}
19391939

1940+
#[test]
1941+
fn completed_generic_executor_outcome_preserves_runtime_evidence_without_provider_run_id() {
1942+
with_isolated_home(|_| {
1943+
let mut plan = test_plan();
1944+
plan.tasks[0].executor.backend = "opencode".to_string();
1945+
plan.tasks[0].executor.model = Some("openai/gpt-5.6-terra".to_string());
1946+
let aggregate = succeeded_aggregate(&plan);
1947+
1948+
let record = record_completed_run(&plan, &aggregate, Some("generic-executor-outcome"))
1949+
.expect("recorded");
1950+
let runtime = record
1951+
.lifecycle
1952+
.provider_runtime
1953+
.first()
1954+
.expect("canonical executor runtime evidence");
1955+
1956+
assert!(record.provider_handles.is_empty());
1957+
assert_eq!(record.metadata["provider_run_ids"], json!([]));
1958+
assert_eq!(runtime.backend, "opencode");
1959+
assert_eq!(runtime.state, ProviderRuntimeState::Succeeded);
1960+
assert!(runtime.external_runtime_ids.is_empty());
1961+
assert_eq!(
1962+
runtime.metadata["evidence_source"],
1963+
"canonical_executor_outcome"
1964+
);
1965+
assert_eq!(
1966+
runtime.metadata["executor"]["model"],
1967+
"openai/gpt-5.6-terra"
1968+
);
1969+
});
1970+
}
1971+
19401972
#[test]
19411973
fn submitted_run_can_be_loaded_marked_running_and_completed() {
19421974
with_isolated_home(|_| {

0 commit comments

Comments
 (0)