Skip to content

Commit 7e3c664

Browse files
authored
Merge pull request #10546 from Extra-Chill/fix/9624-runner-failure-evidence
2 parents aae0004 + b858073 commit 7e3c664

5 files changed

Lines changed: 709 additions & 29 deletions

File tree

crates/homeboy-cli/src/commands/runs/evidence.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,19 @@ mod tests {
162162
"scenario_metrics": [{"scenario_id":"cold","metrics":{"p95_ms":42.0}}],
163163
"resource_policy": {"hot_command":"bench"},
164164
"lab": {
165+
"failure": {
166+
"schema": "homeboy/runner-exec-failure-projection/v1",
167+
"failure_code": "validation.invalid_argument",
168+
"phase": "preflight",
169+
"exit_code": 1,
170+
"runner_id": "lab-default",
171+
"runner_job_id": "job-1",
172+
"stderr_tail": "invalid input",
173+
"stderr_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
174+
"runner_job_logs_command": "homeboy runner job logs lab-default job-1",
175+
"remote_command_result_command": "homeboy runner job logs lab-default job-1 --json",
176+
"artifact_refs": []
177+
},
165178
"remote_events": [{
166179
"data": {
167180
"data": {
@@ -310,6 +323,17 @@ mod tests {
310323
.contains("cleanup-persisted --run-id"));
311324
assert!(output.failure.failed);
312325
assert_eq!(output.failure.exit_code, Some(1));
326+
let runner_failure = output.failure.runner_failure.expect("runner failure");
327+
assert_eq!(
328+
runner_failure.failure_code.as_deref(),
329+
Some("validation.invalid_argument")
330+
);
331+
assert_eq!(runner_failure.phase.as_deref(), Some("preflight"));
332+
assert_eq!(runner_failure.runner_job_id, "job-1");
333+
assert_eq!(
334+
runner_failure.runner_job_logs_command,
335+
"homeboy runner job logs lab-default job-1"
336+
);
313337
assert_eq!(output.failure.gate_failures, vec!["p95_ms exceeded"]);
314338
assert_eq!(output.failure.hints, vec!["inspect artifacts"]);
315339
assert_eq!(

crates/homeboy-core/src/observation/evidence_report.rs

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
use std::fs;
1919
use std::path::{Path, PathBuf};
2020

21-
use serde::Serialize;
21+
use serde::{Deserialize, Serialize};
2222
use serde_json::Value;
2323

2424
use super::{run_owner_pid, running_status_note, ArtifactRecord, RunRecord};
@@ -249,6 +249,81 @@ pub struct EvidenceFailureSummary {
249249
pub hints: Vec<String>,
250250
#[serde(default, skip_serializing_if = "Vec::is_empty")]
251251
pub child_command_failures: Vec<Value>,
252+
/// Runner-owned terminal diagnostics projected into controller evidence.
253+
/// Missing for local and pre-projection records to preserve their schema.
254+
#[serde(skip_serializing_if = "Option::is_none")]
255+
pub runner_failure: Option<RunnerFailureEvidence>,
256+
}
257+
258+
/// Versioned, controller-owned projection of a runner terminal failure.
259+
/// Unknown or malformed legacy metadata is omitted from evidence rather than
260+
/// making `runs evidence` unreadable.
261+
#[derive(Debug, Clone, Serialize, Deserialize)]
262+
pub struct RunnerFailureEvidence {
263+
pub schema: String,
264+
#[serde(default, skip_serializing_if = "Option::is_none")]
265+
pub failure_code: Option<String>,
266+
#[serde(default, skip_serializing_if = "Option::is_none")]
267+
pub message: Option<String>,
268+
#[serde(default, skip_serializing_if = "Option::is_none")]
269+
pub details: Option<Value>,
270+
#[serde(default, skip_serializing_if = "Option::is_none")]
271+
pub phase: Option<String>,
272+
pub exit_code: i64,
273+
#[serde(default, skip_serializing_if = "Option::is_none")]
274+
pub signal: Option<String>,
275+
pub stderr_tail: String,
276+
/// Digest input is the original runner stderr bytes, before redaction.
277+
pub stderr_sha256: String,
278+
pub runner_id: String,
279+
pub runner_job_id: String,
280+
pub runner_job_logs_command: String,
281+
pub remote_command_result_command: String,
282+
#[serde(default, skip_serializing_if = "Option::is_none")]
283+
pub source_snapshot: Option<Value>,
284+
#[serde(default, skip_serializing_if = "Option::is_none")]
285+
pub path_materialization_plan: Option<Value>,
286+
#[serde(default, skip_serializing_if = "Option::is_none")]
287+
pub runner_job_projection: Option<Value>,
288+
#[serde(default, skip_serializing_if = "Option::is_none")]
289+
pub execution_record: Option<Value>,
290+
#[serde(default, skip_serializing_if = "Option::is_none")]
291+
pub orchestration_provenance: Option<Value>,
292+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
293+
pub artifact_refs: Vec<RunnerFailureArtifactRef>,
294+
}
295+
296+
#[derive(Debug, Clone, Serialize, Deserialize)]
297+
pub struct RunnerFailureArtifactRef {
298+
pub id: String,
299+
pub kind: String,
300+
pub path: String,
301+
pub sha256: String,
302+
pub size_bytes: i64,
303+
}
304+
305+
impl RunnerFailureEvidence {
306+
pub const SCHEMA: &'static str = "homeboy/runner-exec-failure-projection/v1";
307+
308+
pub fn from_metadata(value: &Value) -> Option<Self> {
309+
let evidence: Self = serde_json::from_value(value.clone()).ok()?;
310+
(evidence.schema == Self::SCHEMA
311+
&& !evidence.runner_id.is_empty()
312+
&& !evidence.runner_job_id.is_empty()
313+
&& evidence.stderr_sha256.len() == 64
314+
&& evidence
315+
.stderr_sha256
316+
.bytes()
317+
.all(|byte| byte.is_ascii_hexdigit())
318+
&& evidence.artifact_refs.iter().all(|artifact| {
319+
!artifact.id.is_empty()
320+
&& !artifact.path.is_empty()
321+
&& artifact.sha256.len() == 64
322+
&& artifact.sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
323+
&& artifact.size_bytes >= 0
324+
}))
325+
.then_some(evidence)
326+
}
252327
}
253328

254329
#[derive(Serialize)]
@@ -643,6 +718,9 @@ pub fn evidence_failure_summary(run: &RunRecord) -> EvidenceFailureSummary {
643718
gate_failures: string_array(metadata.get("gate_failures")),
644719
hints: string_array(metadata.get("hints")),
645720
child_command_failures: child_command_failures(metadata),
721+
runner_failure: metadata
722+
.pointer("/lab/failure")
723+
.and_then(RunnerFailureEvidence::from_metadata),
646724
}
647725
}
648726

crates/homeboy-lab-runner/src/connection.rs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1798,17 +1798,68 @@ fn parse_runner_jobs(
17981798

17991799
pub fn reverse_broker_reconcile(runner_id: &str) -> Result<Value> {
18001800
let broker_url = reverse_broker_url(runner_id)?;
1801+
reverse_broker_reconcile_at(runner_id, &broker_url)
1802+
}
1803+
1804+
/// Reconcile through a persisted reverse broker endpoint without consulting a
1805+
/// live controller-side session.
1806+
pub(crate) fn reverse_broker_reconcile_at(runner_id: &str, broker_url: &str) -> Result<Value> {
18011807
let client = broker_client("build broker reconcile client")?;
18021808
broker_http::post_json(
18031809
&client,
1804-
&broker_url,
1810+
broker_url,
18051811
"/runner/jobs/reconcile",
18061812
serde_json::json!({ "runner_id": runner_id }),
18071813
"reconcile reverse runner broker jobs",
18081814
broker_auth::broker_submit_token_for_runner(runner_id)?.as_deref(),
18091815
)
18101816
}
18111817

1818+
/// Read a reverse runner job from its persisted broker endpoint. This remains
1819+
/// available after a controller restart because it does not require a live
1820+
/// controller-side runner session.
1821+
pub(crate) fn reverse_broker_job_snapshot_at(
1822+
broker_url: &str,
1823+
runner_id: &str,
1824+
job_id: &str,
1825+
) -> Result<(Job, Vec<homeboy_core::api_jobs::JobEvent>)> {
1826+
let client = broker_client("build reverse broker job snapshot client")?;
1827+
let token = broker_auth::broker_submit_token_for_runner(runner_id)?;
1828+
let job_data = broker_http::get_json(
1829+
&client,
1830+
broker_url,
1831+
&format!("/runner/jobs/{job_id}"),
1832+
"fetch reverse runner broker job",
1833+
token.as_deref(),
1834+
)?;
1835+
let events_data = broker_http::get_json(
1836+
&client,
1837+
broker_url,
1838+
&format!("/runner/jobs/{job_id}/events"),
1839+
"fetch reverse runner broker job events",
1840+
token.as_deref(),
1841+
)?;
1842+
let job: Job = serde_json::from_value(job_data["job"].clone()).map_err(|error| {
1843+
Error::internal_json(
1844+
error.to_string(),
1845+
Some("parse reverse broker job".to_string()),
1846+
)
1847+
})?;
1848+
if job.id.to_string() != job_id {
1849+
return Err(Error::internal_unexpected(format!(
1850+
"reverse broker returned job `{}` while polling requested job `{job_id}`",
1851+
job.id
1852+
)));
1853+
}
1854+
let events = serde_json::from_value(events_data["events"].clone()).map_err(|error| {
1855+
Error::internal_json(
1856+
error.to_string(),
1857+
Some("parse reverse broker job events".to_string()),
1858+
)
1859+
})?;
1860+
Ok((job, events))
1861+
}
1862+
18121863
/// Reconcile terminal runner jobs through the session's authoritative transport.
18131864
/// The returned body is transport-neutral so callers retain one command contract.
18141865
pub fn reconcile_terminal_jobs(runner_id: &str) -> Result<Value> {

0 commit comments

Comments
 (0)