Skip to content

Commit a07278d

Browse files
chubes4Chris Huber
andauthored
fix(agent-task): retain service startup evidence (#11884)
AI assistance: OpenAI GPT-5.6 Sol via OpenCode implemented managed-service startup evidence retention and deterministic tests. Chris Huber remains responsible for every line. Co-authored-by: Chris Huber <chris@chubes.net>
1 parent 40fb123 commit a07278d

3 files changed

Lines changed: 129 additions & 5 deletions

File tree

crates/homeboy-agents/src/agent_task_scheduler/engine.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ where
104104
) {
105105
Ok(services) => Some(services),
106106
Err(error) => {
107+
let (evidence_refs, evidence) =
108+
super::managed_services::startup_failure_evidence(&scratch_run_id);
107109
let outcomes = plan
108110
.tasks
109111
.iter()
@@ -114,10 +116,11 @@ where
114116
failure_classification: Some(
115117
AgentTaskFailureClassification::ExecutionFailed,
116118
),
119+
evidence_refs: evidence_refs.clone(),
117120
diagnostics: vec![AgentTaskDiagnostic {
118121
class: "managed_service_startup".to_string(),
119122
message: error.clone(),
120-
data: serde_json::Value::Null,
123+
data: evidence.clone(),
121124
}],
122125
..Default::default()
123126
})

crates/homeboy-agents/src/agent_task_scheduler/managed_services.rs

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
//! provider, keeping this contract independent of any product integration.
66
77
use std::fs::{File, OpenOptions};
8+
use std::io::Read;
89
use std::net::{TcpListener, TcpStream};
910
use std::path::{Path, PathBuf};
1011
use std::process::{Child, Command, Stdio};
@@ -20,7 +21,8 @@ use homeboy_core::process::{
2021
use serde_json::{json, Value};
2122

2223
use super::{
23-
AgentTaskManagedService, AgentTaskManagedServiceLifecycle, AgentTaskManagedServiceReadinessKind,
24+
AgentTaskEvidenceRef, AgentTaskManagedService, AgentTaskManagedServiceLifecycle,
25+
AgentTaskManagedServiceReadinessKind,
2426
};
2527

2628
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -33,6 +35,11 @@ pub struct AgentTaskManagedServiceRecord {
3335
pub local_url: Option<String>,
3436
pub public_url: Option<String>,
3537
pub log_path: Option<String>,
38+
/// The supervisor combines process stdout and stderr in one retained file.
39+
#[serde(default, skip_serializing_if = "Option::is_none")]
40+
pub stdout_uri: Option<String>,
41+
#[serde(default, skip_serializing_if = "Option::is_none")]
42+
pub stderr_uri: Option<String>,
3643
pub pid: Option<u32>,
3744
pub cleanup: Option<String>,
3845
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -210,6 +217,8 @@ impl AgentTaskServiceSupervisor {
210217
local_url: local_url.clone(),
211218
public_url: spec.public_url.clone(),
212219
log_path: Some(log_path.display().to_string()),
220+
stdout_uri: Some(format!("file://{}", log_path.display())),
221+
stderr_uri: Some(format!("file://{}", log_path.display())),
213222
pid: None,
214223
cleanup: None,
215224
requested_cleanup_deadline_ms: Some(spec.cleanup_deadline_ms),
@@ -725,6 +734,97 @@ fn persist_record(run_id: &str, record: &AgentTaskManagedServiceRecord) -> Resul
725734
.map_err(|error| format!("commit managed service ownership: {error}"))
726735
}
727736

737+
pub(super) fn startup_failure_evidence(run_id: &str) -> (Vec<AgentTaskEvidenceRef>, Value) {
738+
let records = read_service_records(run_id);
739+
let services = records
740+
.iter()
741+
.map(|record| {
742+
let record_uri = service_record_path(run_id, &record.id)
743+
.map(|path| format!("file://{}", path.display()));
744+
json!({
745+
"id": record.id,
746+
"state": record.state,
747+
"record_uri": record_uri,
748+
"stdout_uri": record.stdout_uri,
749+
"stderr_uri": record.stderr_uri,
750+
"stdout_excerpt": record.log_path.as_deref().and_then(redacted_log_excerpt),
751+
"readiness_attempts": record.readiness_attempts,
752+
"cleanup": record.cleanup,
753+
"cleanup_outcome": record.cleanup_outcome,
754+
})
755+
})
756+
.collect::<Vec<_>>();
757+
let mut evidence_refs = Vec::new();
758+
for record in &records {
759+
if let Ok(path) = service_record_path(run_id, &record.id) {
760+
evidence_refs.push(AgentTaskEvidenceRef {
761+
kind: "managed-service-record".to_string(),
762+
uri: format!("file://{}", path.display()),
763+
label: Some(format!("managed service '{}' startup record", record.id)),
764+
});
765+
}
766+
for (kind, uri) in [
767+
("managed-service-stdout", record.stdout_uri.as_ref()),
768+
("managed-service-stderr", record.stderr_uri.as_ref()),
769+
] {
770+
if let Some(uri) = uri {
771+
evidence_refs.push(AgentTaskEvidenceRef {
772+
kind: kind.to_string(),
773+
uri: uri.clone(),
774+
label: Some(format!("managed service '{}' {kind}", record.id)),
775+
});
776+
}
777+
}
778+
}
779+
(
780+
evidence_refs,
781+
json!({
782+
"run_id": run_id,
783+
"service_supervisor_state_uri": service_worker_state_path(run_id)
784+
.ok()
785+
.map(|path| format!("file://{}", path.display())),
786+
"services": services,
787+
}),
788+
)
789+
}
790+
791+
fn redacted_log_excerpt(path: &str) -> Option<String> {
792+
let mut bytes = Vec::with_capacity(4 * 1024);
793+
File::open(path)
794+
.ok()?
795+
.take(4 * 1024)
796+
.read_to_end(&mut bytes)
797+
.ok()?;
798+
Some(homeboy_core::redaction::redact_string(
799+
&String::from_utf8_lossy(&bytes),
800+
))
801+
}
802+
803+
fn service_record_path(run_id: &str, service_id: &str) -> Result<PathBuf, String> {
804+
Ok(homeboy_core::paths::homeboy_data()
805+
.map_err(|error| error.message)?
806+
.join("agent-task-runs")
807+
.join(run_id)
808+
.join("services")
809+
.join(format!("{service_id}.json")))
810+
}
811+
812+
fn read_service_records(run_id: &str) -> Vec<AgentTaskManagedServiceRecord> {
813+
let directory = match homeboy_core::paths::homeboy_data() {
814+
Ok(path) => path.join("agent-task-runs").join(run_id).join("services"),
815+
Err(_) => return Vec::new(),
816+
};
817+
let Ok(entries) = std::fs::read_dir(directory) else {
818+
return Vec::new();
819+
};
820+
entries
821+
.flatten()
822+
.filter(|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("json"))
823+
.filter_map(|entry| std::fs::read(entry.path()).ok())
824+
.filter_map(|bytes| serde_json::from_slice(&bytes).ok())
825+
.collect()
826+
}
827+
728828
fn worker_root(run_id: &str) -> Result<PathBuf, String> {
729829
Ok(homeboy_core::paths::homeboy_data()
730830
.map_err(|error| error.message)?
@@ -867,6 +967,7 @@ pub fn run_service_worker(request_path: &Path) -> Result<(), String> {
867967
Err(error) => {
868968
state.state = "failed".to_string();
869969
state.detail = Some(error);
970+
state.services = read_service_records(&request.run_id);
870971
state.heartbeat_unix_ms = now_unix_ms();
871972
return write_json_atomically(&state_path, &state);
872973
}
@@ -1605,7 +1706,8 @@ mod tests {
16051706
service.command = vec![
16061707
"sh".to_string(),
16071708
"-c".to_string(),
1608-
"trap '' TERM; while :; do sleep 1; done".to_string(),
1709+
"echo token=managed-service-secret >&2; trap '' TERM; while :; do sleep 1; done"
1710+
.to_string(),
16091711
];
16101712
service.readiness.as_mut().unwrap().timeout_ms = Some(50);
16111713
service.cleanup_deadline_ms = 100;
@@ -1620,6 +1722,11 @@ mod tests {
16201722
assert!(error.contains("readiness probe timed out"));
16211723
worker.join().expect("worker joins").expect("worker exits");
16221724
let state = wait_for_worker_state(run_id, "failed");
1725+
assert_eq!(
1726+
state.services.len(),
1727+
1,
1728+
"failed services remain discoverable"
1729+
);
16231730
assert!(state
16241731
.detail
16251732
.as_deref()
@@ -1646,6 +1753,15 @@ mod tests {
16461753
Some("deadline_escalation_forced")
16471754
);
16481755
assert!(record.pid.is_some_and(|pid| !super::process_is_alive(pid)));
1756+
assert_eq!(record.stdout_uri, record.stderr_uri);
1757+
1758+
let (refs, evidence) = startup_failure_evidence(run_id);
1759+
assert_eq!(refs.len(), 3, "record plus stdout and stderr refs");
1760+
assert_eq!(evidence["services"][0]["state"], "failed");
1761+
assert!(evidence["services"][0]["stdout_uri"]
1762+
.as_str()
1763+
.is_some_and(|uri| uri.starts_with("file://")));
1764+
assert!(!evidence.to_string().contains("managed-service-secret"));
16491765
});
16501766
}
16511767

crates/homeboy-cli/src/commands/agent_task/run.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1436,10 +1436,15 @@ pub(super) fn run_plan(args: RunPlanArgs) -> CmdResult<Value> {
14361436
if let Some(timeout_ms) = args.timeout_ms {
14371437
plan.options.timeout_ms = Some(timeout_ms);
14381438
}
1439-
emit_runner_lifecycle_progress(&plan, args.record_run_id.as_deref());
1439+
// Managed services own durable process logs, so a direct plan invocation
1440+
// needs a run identity even when the caller did not provide one.
1441+
let record_run_id = args.record_run_id.or_else(|| {
1442+
(!plan.services.is_empty()).then(|| format!("run-plan-{}", uuid::Uuid::new_v4()))
1443+
});
1444+
emit_runner_lifecycle_progress(&plan, record_run_id.as_deref());
14401445
run_loaded_plan(
14411446
plan,
1442-
args.record_run_id.as_deref(),
1447+
record_run_id.as_deref(),
14431448
ExtensionProviderAgentTaskExecutor::discover(),
14441449
)
14451450
}

0 commit comments

Comments
 (0)