Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions src/core/agent_task_aggregate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ pub struct AgentTaskReconciliationItem {
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AgentTaskReconciliationDecision {
NoOp,
ApplyCandidate,
IssueReportCandidate,
RetryCandidate,
Expand Down Expand Up @@ -151,6 +152,7 @@ fn aggregate_agent_task_outcomes(outcomes: &[AgentTaskOutcome]) -> AgentTaskAggr
};

match decision {
AgentTaskReconciliationDecision::NoOp => {}
AgentTaskReconciliationDecision::ApplyCandidate => {
report.summary.apply_candidates += 1;
report.apply_candidates.push(decision_ref);
Expand Down Expand Up @@ -275,6 +277,7 @@ impl AgentTaskOutcomeStatus {
impl AgentTaskReconciliationDecision {
pub fn as_str(self) -> &'static str {
match self {
Self::NoOp => "no_op",
Self::ApplyCandidate => "apply_candidate",
Self::IssueReportCandidate => "issue_report_candidate",
Self::RetryCandidate => "retry_candidate",
Expand All @@ -299,6 +302,13 @@ fn count_status(summary: &mut AgentTaskAggregateSummary, status: AgentTaskOutcom
fn reconcile_outcome(
outcome: &AgentTaskOutcome,
) -> (AgentTaskReconciliationDecision, String, Vec<String>) {
if outcome.status == AgentTaskOutcomeStatus::NoOp && has_known_empty_change_set(outcome) {
return (
AgentTaskReconciliationDecision::NoOp,
"known empty change artifact; provider produced no file changes".to_string(),
Vec::new(),
);
}
let rejected_artifact_ids = outcome
.artifacts
.iter()
Expand Down Expand Up @@ -382,6 +392,18 @@ fn reconcile_outcome(
)
}

fn has_known_empty_change_set(outcome: &AgentTaskOutcome) -> bool {
let patch_artifacts = outcome
.artifacts
.iter()
.filter(|artifact| is_apply_kind_artifact(artifact))
.collect::<Vec<_>>();
!patch_artifacts.is_empty()
&& patch_artifacts
.iter()
.all(|artifact| artifact.size_bytes == Some(0))
}

fn artifact_ids(outcome: &AgentTaskOutcome) -> Vec<String> {
outcome
.artifacts
Expand Down Expand Up @@ -594,17 +616,22 @@ mod tests {

let report = aggregate_agent_task_outcomes(&[outcome(
"empty-patch",
AgentTaskOutcomeStatus::Succeeded,
AgentTaskOutcomeStatus::NoOp,
vec![empty_patch],
)]);

assert!(report.apply_candidates.is_empty());
assert_eq!(report.summary.apply_candidates, 0);
assert_eq!(report.summary.review_candidates, 1);
assert_eq!(report.review_candidates[0].task_id, "empty-patch");
assert!(report.review_candidates.is_empty());
assert_eq!(report.summary.review_candidates, 0);
assert_eq!(report.summary.no_op, 1);
assert_eq!(
report.review_candidates[0].reason,
"patch artifact was empty (0 bytes); provider produced no file changes"
report.tasks[0].decision,
AgentTaskReconciliationDecision::NoOp
);
assert_eq!(
report.tasks[0].reason,
"known empty change artifact; provider produced no file changes"
);
}

Expand Down
3 changes: 2 additions & 1 deletion src/core/agent_task_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ use command_runner::{
use fixtures::fixture_artifact;
#[cfg(test)]
use outcome_normalization::{
normalize_provider_outcome_roles, surface_provider_run_result_diagnostics,
normalize_homeboy_local_artifact_sizes, normalize_provider_outcome_roles,
surface_provider_run_result_diagnostics,
};
#[cfg(test)]
use resolution::{
Expand Down
10 changes: 9 additions & 1 deletion src/core/agent_task_provider/command_runner.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use super::outcome_normalization::{normalize_provider_outcome_roles, push_unique_diagnostic};
use super::outcome_normalization::{
normalize_homeboy_local_artifact_sizes, normalize_provider_outcome_roles,
push_unique_diagnostic,
};
use super::runner_readiness::{
executable_file, provider_executable_env, resolve_executable_candidate,
};
Expand Down Expand Up @@ -480,6 +483,11 @@ pub(super) fn run_materialized_provider_command_once(
outcome.schema = AGENT_TASK_OUTCOME_SCHEMA.to_string();
}
normalize_provider_outcome_roles(&mut outcome, provider);
normalize_homeboy_local_artifact_sizes(
&mut outcome,
&request.artifacts_path,
&request.artifacts_path_provenance,
);
surface_provider_process_failure(
&mut outcome,
request,
Expand Down
80 changes: 80 additions & 0 deletions src/core/agent_task_provider/outcome_normalization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,86 @@ pub(super) fn normalize_provider_outcome_roles(
}
}

/// Records the actual size of regular files that Homeboy materialized for this
/// executor. Provider-supplied paths are otherwise treated as untrusted.
pub(super) fn normalize_homeboy_local_artifact_sizes(
outcome: &mut AgentTaskOutcome,
artifact_root: &std::path::Path,
provenance: &AgentTaskArtifactsPathProvenance,
) {
if provenance.owner != "homeboy" || provenance.locality != "runner" {
return;
}

let Ok(artifact_root) = artifact_root.canonicalize() else {
return;
};

for artifact in &mut outcome.artifacts {
measure_homeboy_local_artifact(artifact, &artifact_root);
}
for typed_artifact in &mut outcome.typed_artifacts {
if let Some(artifact) = &mut typed_artifact.artifact {
measure_homeboy_local_artifact(artifact, &artifact_root);
if let Some(size_bytes) = artifact.size_bytes {
if let Some(payload) = typed_artifact.payload.as_object_mut() {
payload.insert("size_bytes".to_string(), Value::from(size_bytes));
}
}
}
}

if outcome.status == AgentTaskOutcomeStatus::Succeeded && has_known_empty_change_set(outcome) {
outcome.status = AgentTaskOutcomeStatus::NoOp;
outcome.summary = Some("provider produced an empty change artifact".to_string());
}
}

fn measure_homeboy_local_artifact(
artifact: &mut AgentTaskArtifact,
artifact_root: &std::path::Path,
) {
let Some(path) = artifact.path.as_deref() else {
return;
};
let path = std::path::Path::new(path);
let path = if path.is_absolute() {
path.to_path_buf()
} else {
artifact_root.join(path)
};
let Ok(path) = path.canonicalize() else {
return;
};
if !path.starts_with(artifact_root) {
return;
}
let Ok(metadata) = std::fs::metadata(path) else {
return;
};
if metadata.is_file() {
artifact.size_bytes = Some(metadata.len());
}
}

fn has_known_empty_change_set(outcome: &AgentTaskOutcome) -> bool {
let changes = outcome
.artifacts
.iter()
.filter(|artifact| {
matches!(
artifact.kind.as_str(),
"patch" | "diff" | "change_artifact" | "workspace_patch" | "artifact"
)
})
.collect::<Vec<_>>();

!changes.is_empty()
&& changes
.iter()
.all(|artifact| artifact.size_bytes == Some(0))
}

fn normalize_provider_result_contract(
outcome: &mut AgentTaskOutcome,
provider: &AgentTaskExecutorProvider,
Expand Down
72 changes: 72 additions & 0 deletions src/core/agent_task_provider/tests/outcome_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,78 @@ fn provider_outcome_roles_normalize_from_declared_aliases() {
);
}

#[test]
fn homeboy_local_artifact_normalization_measures_empty_nonempty_and_unavailable_files() {
let root = tempfile::tempdir().expect("artifact root");
let empty_path = root.path().join("empty.patch");
let nonempty_path = root.path().join("nonempty.patch");
fs::write(&empty_path, "").expect("empty patch");
fs::write(&nonempty_path, "diff --git a/a b/a\n").expect("nonempty patch");
let provenance = AgentTaskArtifactsPathProvenance {
owner: "homeboy".to_string(),
locality: "runner".to_string(),
plan_id: "plan".to_string(),
run_id: None,
task_id: "opencode-no-op".to_string(),
attempt: 1,
};
let mut empty = fixture_artifact("empty", "patch", &empty_path, Some("text/x-patch"));
empty.size_bytes = None;
let mut nonempty = fixture_artifact("nonempty", "patch", &nonempty_path, Some("text/x-patch"));
nonempty.size_bytes = None;
let mut unavailable = fixture_artifact(
"foreign",
"patch",
&std::env::temp_dir().join("homeboy-unavailable.patch"),
Some("text/x-patch"),
);
unavailable.size_bytes = None;

let mut empty_outcome = failed_outcome_with_run_result(Value::Null);
empty_outcome.status = AgentTaskOutcomeStatus::Succeeded;
empty_outcome.failure_classification = None;
empty_outcome.artifacts = vec![empty.clone()];
empty_outcome.typed_artifacts = vec![AgentTaskTypedArtifact {
name: "patch".to_string(),
artifact_type: Some("patch".to_string()),
artifact_schema: None,
payload: json!({ "path": empty_path, "size_bytes": null }),
artifact: Some(empty),
metadata: Value::Null,
}];
normalize_homeboy_local_artifact_sizes(&mut empty_outcome, root.path(), &provenance);

assert_eq!(empty_outcome.status, AgentTaskOutcomeStatus::NoOp);
assert_eq!(empty_outcome.artifacts[0].size_bytes, Some(0));
assert_eq!(
empty_outcome.typed_artifacts[0]
.artifact
.as_ref()
.and_then(|artifact| artifact.size_bytes),
Some(0)
);
assert_eq!(empty_outcome.typed_artifacts[0].payload["size_bytes"], 0);

let mut nonempty_outcome = failed_outcome_with_run_result(Value::Null);
nonempty_outcome.status = AgentTaskOutcomeStatus::Succeeded;
nonempty_outcome.failure_classification = None;
nonempty_outcome.artifacts = vec![nonempty];
normalize_homeboy_local_artifact_sizes(&mut nonempty_outcome, root.path(), &provenance);
assert_eq!(nonempty_outcome.status, AgentTaskOutcomeStatus::Succeeded);
assert!(nonempty_outcome.artifacts[0].size_bytes.unwrap_or_default() > 0);

let mut unavailable_outcome = failed_outcome_with_run_result(Value::Null);
unavailable_outcome.status = AgentTaskOutcomeStatus::Succeeded;
unavailable_outcome.failure_classification = None;
unavailable_outcome.artifacts = vec![unavailable];
normalize_homeboy_local_artifact_sizes(&mut unavailable_outcome, root.path(), &provenance);
assert_eq!(
unavailable_outcome.status,
AgentTaskOutcomeStatus::Succeeded
);
assert_eq!(unavailable_outcome.artifacts[0].size_bytes, None);
}

#[test]
fn declared_sandbox_result_contract_rejects_private_runtime_result_shape() {
let (_, mut provider) = request("task-sandbox-private", "node provider.js".to_string());
Expand Down
Loading