Skip to content

Commit eb17908

Browse files
authored
Authenticate failed pre-provider candidate recovery (#9067)
* fix: prefer controller attempt plan in Lab handoff * fix: allow failed pre-provider candidate adoption * fix: authenticate failed pre-provider recovery
1 parent f9514e0 commit eb17908

5 files changed

Lines changed: 255 additions & 51 deletions

File tree

crates/homeboy-agents/src/agent_task_finalization.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -546,8 +546,10 @@ fn validate_durable_publication_eligibility(
546546
.pointer("/candidate/fingerprint/head")
547547
.and_then(serde_json::Value::as_str);
548548
let adoption_model = promotion.provenance["adoption"]["ai_model"].as_str();
549-
let authenticated_adoption = lifecycle.execution.state == RunExecutionState::Cancelled
550-
&& lifecycle.provider_runtime.is_empty()
549+
let authenticated_adoption = matches!(
550+
lifecycle.execution.state,
551+
RunExecutionState::Cancelled | RunExecutionState::Failed
552+
) && no_real_provider_execution(lifecycle)
551553
&& promotion.provenance["adoption"]["source_run_id"]
552554
== promotion.source.run_id.clone().unwrap_or_default()
553555
&& candidate_ref.is_some_and(is_git_commit_identity)
@@ -609,6 +611,14 @@ fn validate_durable_publication_eligibility(
609611
Err(Error::validation_invalid_argument("run_id", "durable run must have succeeded execution and succeeded provider runtime before publication; the only exceptions are an applied, green, fingerprinted candidate-adoption recovery with durable zero-execution pre-provider transport provenance or an applied, green, committed-change-provenance-bound authenticated external candidate adoption", None, None))
610612
}
611613

614+
fn no_real_provider_execution(lifecycle: &RunLifecycleRecord) -> bool {
615+
lifecycle.external_runtime_ids.is_empty()
616+
&& lifecycle.provider_runtime.iter().all(|runtime| {
617+
runtime.external_runtime_ids.is_empty()
618+
&& runtime.metadata["evidence_source"] == "canonical_executor_outcome"
619+
})
620+
}
621+
612622
fn is_concrete_model(value: &str) -> bool {
613623
!value.trim().is_empty()
614624
&& value == value.trim()

crates/homeboy-agents/src/agent_task_finalization/tests.rs

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,36 @@ fn durable_finalization_accepts_only_authenticated_pre_provider_candidate_adopti
835835
assert_eq!(report.review_dossier.ai_assistance.model, "GPT-5.5");
836836
assert!(backend.committed && backend.pushed && backend.created);
837837

838+
let mut failed_recovery_lifecycle = recovery_lifecycle.clone();
839+
failed_recovery_lifecycle.execution.state = RunExecutionState::Failed;
840+
failed_recovery_lifecycle
841+
.provider_runtime
842+
.push(ProviderRuntimeLifecycle {
843+
task_id: "task".to_string(),
844+
backend: "opencode".to_string(),
845+
state: ProviderRuntimeState::Failed,
846+
stream_uri: None,
847+
external_runtime_ids: Vec::new(),
848+
metadata: json!({ "evidence_source": "canonical_executor_outcome" }),
849+
});
850+
let mut failed_backend = MockBackend {
851+
changed_files: vec!["src/lib.rs".to_string()],
852+
lifecycle: Some(failed_recovery_lifecycle.clone()),
853+
gate_proof: Some({
854+
let mut proof = pre_provider_adoption_gate_proof();
855+
proof.promotion.changed_files = vec!["src/lib.rs".to_string()];
856+
proof
857+
}),
858+
..Default::default()
859+
};
860+
let mut failed_finalization_options = options();
861+
failed_finalization_options.manual_finalization = false;
862+
failed_finalization_options.changed_files = vec!["src/lib.rs".to_string()];
863+
let failed_report = finalize_pr_with_backend(failed_finalization_options, &mut failed_backend)
864+
.expect("authenticated failed transport recovery publishes");
865+
assert_eq!(failed_report.status, "review_ready");
866+
assert!(failed_backend.committed && failed_backend.pushed && failed_backend.created);
867+
838868
let rejected = |lifecycle: RunLifecycleRecord, gate_proof: AgentTaskPrDurableGateProof| {
839869
let mut backend = MockBackend {
840870
changed_files: vec!["src/lib.rs".to_string()],
@@ -850,49 +880,54 @@ fn durable_finalization_accepts_only_authenticated_pre_provider_candidate_adopti
850880
assert!(!backend.committed && !backend.pushed && !backend.created);
851881
};
852882

853-
rejected(recovery_lifecycle.clone(), successful_gate_proof());
883+
rejected(failed_recovery_lifecycle.clone(), successful_gate_proof());
854884

855-
let mut provider_executed = recovery_lifecycle.clone();
885+
let mut provider_executed = failed_recovery_lifecycle.clone();
856886
provider_executed
857887
.provider_runtime
858888
.push(ProviderRuntimeLifecycle {
859889
task_id: "task".to_string(),
860890
backend: "provider".to_string(),
861891
state: ProviderRuntimeState::Cancelled,
862892
stream_uri: None,
863-
external_runtime_ids: Vec::new(),
893+
external_runtime_ids: vec![ExternalRuntimeId {
894+
kind: "provider_run_id".to_string(),
895+
value: "provider-actual-run".to_string(),
896+
provider: Some("provider".to_string()),
897+
url: None,
898+
}],
864899
metadata: serde_json::Value::Null,
865900
});
866901
rejected(provider_executed, pre_provider_adoption_gate_proof());
867902

868903
let mut legacy = pre_provider_adoption_gate_proof();
869904
legacy.promotion.provenance["adoption"]["recovery"] = serde_json::Value::Null;
870-
rejected(recovery_lifecycle.clone(), legacy);
905+
rejected(failed_recovery_lifecycle.clone(), legacy);
871906

872907
let mut mismatched = pre_provider_adoption_gate_proof();
873908
mismatched.promotion.provenance["adoption"]["source_run_id"] = json!("other-run");
874-
rejected(recovery_lifecycle.clone(), mismatched);
909+
rejected(failed_recovery_lifecycle.clone(), mismatched);
875910

876911
let mut unbound_candidate = pre_provider_adoption_gate_proof();
877912
unbound_candidate.promotion.provenance["candidate"] = serde_json::Value::Null;
878-
rejected(recovery_lifecycle.clone(), unbound_candidate);
913+
rejected(failed_recovery_lifecycle.clone(), unbound_candidate);
879914

880915
let mut mismatched_head = pre_provider_adoption_gate_proof();
881916
mismatched_head.promotion.provenance["candidate"]["fingerprint"]["head"] =
882917
json!("0000000000000000000000000000000000000000");
883-
rejected(recovery_lifecycle.clone(), mismatched_head);
918+
rejected(failed_recovery_lifecycle.clone(), mismatched_head);
884919

885920
let mut missing_head = pre_provider_adoption_gate_proof();
886921
missing_head.promotion.provenance["candidate"]["fingerprint"]["head"] = json!("");
887-
rejected(recovery_lifecycle.clone(), missing_head);
922+
rejected(failed_recovery_lifecycle.clone(), missing_head);
888923

889924
let mut missing_model = pre_provider_adoption_gate_proof();
890925
missing_model.promotion.provenance["adoption"]["ai_model"] = serde_json::Value::Null;
891-
rejected(recovery_lifecycle.clone(), missing_model);
926+
rejected(failed_recovery_lifecycle.clone(), missing_model);
892927

893928
let mut non_green = pre_provider_adoption_gate_proof();
894929
non_green.promotion.gate_results[0].status = HomeboyGateStatus::Failed;
895-
rejected(recovery_lifecycle, non_green);
930+
rejected(failed_recovery_lifecycle, non_green);
896931
}
897932

898933
#[test]

crates/homeboy-agents/src/agent_task_lifecycle/lifecycle_ops.rs

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1260,28 +1260,44 @@ pub(crate) fn is_accepted_runner_handoff(record: &AgentTaskRunRecord) -> bool {
12601260
record.has_accepted_lab_handoff()
12611261
}
12621262

1263-
/// Reconstruct the only aggregate-free failure that can safely admit an
1264-
/// externally prepared immutable candidate: an expired handoff before the
1265-
/// runner recorded a job or consumed a provider execution.
1263+
/// Reconstruct an authenticated pre-provider transport failure that can safely
1264+
/// admit an externally prepared immutable candidate. Expired handoffs retain
1265+
/// their aggregate-free legacy shape; preacceptance failures retain their
1266+
/// canonical failure aggregate and its synthetic runtime projection.
12661267
pub fn candidate_adoption_recovery_outcome(
12671268
record: &AgentTaskRunRecord,
12681269
task: &AgentTaskRequest,
12691270
) -> Option<AgentTaskOutcome> {
1270-
let handoff = record.lab_handoff.as_ref()?;
1271-
let eligible = record.state == AgentTaskRunState::Cancelled
1272-
&& record.aggregate_path.is_none()
1273-
&& record.totals.is_none()
1274-
&& record.artifact_refs.is_empty()
1275-
&& record.provider_handles.is_empty()
1276-
&& record.latest_executor_evidence.is_none()
1277-
&& record.lab_handoff_validation_error().is_none()
1278-
&& handoff.state == AgentTaskLabHandoffState::Expired
1279-
&& handoff.runner_job_id.is_none()
1280-
&& record.metadata["phase"] == "handoff_rejected"
1271+
let expired_handoff = record.lab_handoff.as_ref().is_some_and(|handoff| {
1272+
record.state == AgentTaskRunState::Cancelled
1273+
&& record.aggregate_path.is_none()
1274+
&& record.totals.is_none()
1275+
&& record.artifact_refs.is_empty()
1276+
&& record.provider_handles.is_empty()
1277+
&& record.latest_executor_evidence.is_none()
1278+
&& record.lab_handoff_validation_error().is_none()
1279+
&& handoff.state == AgentTaskLabHandoffState::Expired
1280+
&& handoff.runner_job_id.is_none()
1281+
&& record.metadata["phase"] == "handoff_rejected"
1282+
&& record.metadata["provider_executions_consumed"] == 0
1283+
&& record.metadata["handoff_acceptance"]["state"] == "expired"
1284+
&& record.metadata["handoff_acceptance"]["reason"] == EXPIRED_LAB_HANDOFF_REASON
1285+
});
1286+
let failed_preacceptance = record.state == AgentTaskRunState::Failed
1287+
&& record.metadata["phase"] == "lab_handoff_preacceptance"
12811288
&& record.metadata["provider_executions_consumed"] == 0
1282-
&& record.metadata["handoff_acceptance"]["state"] == "expired"
1283-
&& record.metadata["handoff_acceptance"]["reason"] == EXPIRED_LAB_HANDOFF_REASON;
1284-
eligible.then(|| {
1289+
&& record.provider_handles.is_empty()
1290+
&& no_runner_job_recorded(record)
1291+
&& record.lifecycle.external_runtime_ids.is_empty()
1292+
&& record.lifecycle.provider_runtime.iter().all(|runtime| {
1293+
runtime.external_runtime_ids.is_empty()
1294+
&& runtime.metadata["evidence_source"] == "canonical_executor_outcome"
1295+
})
1296+
&& record.metadata["pre_execution_failure"]["phase"] == "lab_handoff_preacceptance"
1297+
&& is_pre_provider_transport_recovery(
1298+
&record.metadata["pre_execution_failure"]["candidate_adoption_recovery"],
1299+
);
1300+
(expired_handoff || failed_preacceptance).then(|| {
12851301
build_pre_execution_failure_outcome(
12861302
&record.run_id,
12871303
task,
@@ -1291,6 +1307,22 @@ pub fn candidate_adoption_recovery_outcome(
12911307
})
12921308
}
12931309

1310+
fn no_runner_job_recorded(record: &AgentTaskRunRecord) -> bool {
1311+
record.runner_job_id().is_none()
1312+
&& record
1313+
.lab_handoff
1314+
.as_ref()
1315+
.is_none_or(|handoff| handoff.runner_job_id.is_none())
1316+
&& record.metadata["runner_job_id"].is_null()
1317+
&& record.metadata["job_id"].is_null()
1318+
}
1319+
1320+
fn is_pre_provider_transport_recovery(recovery: &Value) -> bool {
1321+
recovery["schema"] == "homeboy/agent-task-candidate-adoption-recovery/v1"
1322+
&& recovery["reason"] == "pre_provider_transport_failure"
1323+
&& recovery["provider_executions_consumed"] == 0
1324+
}
1325+
12941326
fn expire_unaccepted_lab_handoff(run_id: &str) -> Result<bool> {
12951327
// An expired pending request may have been accepted immediately before its
12961328
// response was lost. Querying its key is read-only; never replay here.

crates/homeboy-agents/src/agent_task_lifecycle/tests/terminal_and_reconcile.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,85 @@ fn detached_cook_preacceptance_failure_terminalizes_attempt_proxy() {
233233
});
234234
}
235235

236+
#[test]
237+
fn failed_lab_preacceptance_reconstructs_only_authenticated_zero_execution_recovery() {
238+
with_isolated_home(|_| {
239+
let run_id = "cook-preacceptance-recovery";
240+
let plan = test_plan();
241+
record_lab_offload_phase(
242+
run_id,
243+
"homeboy-lab",
244+
"materializing",
245+
None,
246+
None,
247+
None,
248+
Some(&plan),
249+
)
250+
.expect("pre-acceptance attempt record");
251+
record_pre_execution_failure(
252+
run_id,
253+
&plan,
254+
"lab_handoff_preacceptance",
255+
&Error::internal_unexpected("truncated Lab handoff payload"),
256+
)
257+
.expect("terminal preacceptance failure");
258+
259+
let mut record = status(run_id).expect("failed record");
260+
record.metadata["phase"] = json!("lab_handoff_preacceptance");
261+
assert_eq!(record.state, AgentTaskRunState::Failed);
262+
assert!(record.aggregate_path.is_some());
263+
assert!(!record.artifact_refs.is_empty());
264+
assert_eq!(record.lifecycle.provider_runtime.len(), 1);
265+
assert_eq!(
266+
record.lifecycle.provider_runtime[0].metadata["evidence_source"],
267+
"canonical_executor_outcome"
268+
);
269+
assert!(candidate_adoption_recovery_outcome(&record, &plan.tasks[0]).is_some());
270+
271+
let mut wrong_phase = record.clone();
272+
wrong_phase.metadata["phase"] = json!("provider_dispatch");
273+
assert!(candidate_adoption_recovery_outcome(&wrong_phase, &plan.tasks[0]).is_none());
274+
275+
let mut consumed_execution = record.clone();
276+
consumed_execution.metadata["provider_executions_consumed"] = json!(1);
277+
assert!(candidate_adoption_recovery_outcome(&consumed_execution, &plan.tasks[0]).is_none());
278+
279+
let mut provider_handle = record.clone();
280+
provider_handle
281+
.provider_handles
282+
.push(AgentTaskRunProviderHandle {
283+
kind: Default::default(),
284+
task_id: "task-a".to_string(),
285+
backend: "test".to_string(),
286+
provider_run_id: "provider-actual-run".to_string(),
287+
stream_uri: None,
288+
state: Some(AgentTaskState::Failed),
289+
metadata: Value::Null,
290+
});
291+
assert!(candidate_adoption_recovery_outcome(&provider_handle, &plan.tasks[0]).is_none());
292+
293+
let mut runner_job = record.clone();
294+
runner_job.metadata["runner_job_id"] = json!("job-actual-provider");
295+
assert!(candidate_adoption_recovery_outcome(&runner_job, &plan.tasks[0]).is_none());
296+
297+
let mut provider_runtime = record.clone();
298+
provider_runtime.lifecycle.provider_runtime[0]
299+
.external_runtime_ids
300+
.push(homeboy_core::run_lifecycle_record::ExternalRuntimeId {
301+
kind: "provider_run_id".to_string(),
302+
value: "provider-actual-run".to_string(),
303+
provider: Some("test".to_string()),
304+
url: None,
305+
});
306+
assert!(candidate_adoption_recovery_outcome(&provider_runtime, &plan.tasks[0]).is_none());
307+
308+
let mut changed_recovery = record;
309+
changed_recovery.metadata["pre_execution_failure"]["candidate_adoption_recovery"]
310+
["reason"] = json!("provider_failure");
311+
assert!(candidate_adoption_recovery_outcome(&changed_recovery, &plan.tasks[0]).is_none());
312+
});
313+
}
314+
236315
#[test]
237316
fn failed_lab_handoff_retry_recovers_the_materialized_user_plan() {
238317
with_isolated_home(|_| {

0 commit comments

Comments
 (0)