Skip to content

Commit e3d81d6

Browse files
author
Chris Huber
committed
fix(runner): align doctor exit with readiness
AI assistance: OpenAI GPT-5.6 Sol via OpenCode inspected the doctor/result contracts, implemented the exit semantics, and added focused tests. Chris Huber remains responsible for every line.
1 parent 7d4fedd commit e3d81d6

6 files changed

Lines changed: 61 additions & 2 deletions

File tree

crates/homeboy-cli/src/commands/runner/doctor/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ pub fn run_with_options(
7575
}
7676

7777
report.status = checks::overall_status(&report.checks);
78-
Ok((report, 0))
78+
let exit_code = report.status.operational_exit_code();
79+
Ok((report, exit_code))
7980
}
8081

8182
fn runner_summary(

crates/homeboy-cli/src/commands/runner/doctor/tests/shape.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,21 @@ fn overall_status_promotes_errors_over_warnings() {
6464
];
6565
assert_eq!(checks::overall_status(&checks), RunnerDoctorStatus::Error);
6666
}
67+
68+
#[test]
69+
fn operational_exit_code_matches_the_doctor_readiness_verdict() {
70+
for (scenario, status, expected_exit_code) in [
71+
("healthy", RunnerDoctorStatus::Ok, 0),
72+
("degraded", RunnerDoctorStatus::Warning, 0),
73+
// A disconnected runner with a recoverable daemon is still not ready
74+
// until `--repair` has rerun its probe successfully.
75+
("disconnected_recoverable", RunnerDoctorStatus::Error, 1),
76+
("terminal_error", RunnerDoctorStatus::Error, 1),
77+
] {
78+
assert_eq!(
79+
status.operational_exit_code(),
80+
expected_exit_code,
81+
"{scenario}"
82+
);
83+
}
84+
}

crates/homeboy-cli/src/commands/runner/doctor/tests/tools.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ fn local_doctor_honors_required_tool_errors() {
7575
)
7676
.expect("local doctor report");
7777

78-
assert_eq!(exit_code, 0);
78+
assert_eq!(exit_code, 1);
7979
assert_eq!(report.status, RunnerDoctorStatus::Error);
8080
assert!(report.checks.iter().any(|check| {
8181
check.id == "tool.required.homeboy-definitely-missing-tool"

crates/homeboy-cli/src/commands/runner/doctor/types.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ pub enum RunnerDoctorStatus {
99
Error,
1010
}
1111

12+
impl RunnerDoctorStatus {
13+
/// The process result for a completed doctor report. Warnings remain
14+
/// diagnostic-only; error-level checks make the runner not ready.
15+
pub const fn operational_exit_code(self) -> i32 {
16+
match self {
17+
Self::Ok | Self::Warning => 0,
18+
Self::Error => 1,
19+
}
20+
}
21+
}
22+
1223
#[derive(Debug, Serialize)]
1324
pub struct RunnerDoctorOutput {
1425
pub variant: &'static str,

crates/homeboy-cli/src/commands/utils/response.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1433,6 +1433,33 @@ mod tests {
14331433
assert!(value.get("subject_state").is_none());
14341434
}
14351435

1436+
#[test]
1437+
fn doctor_readiness_verdict_serializes_consistently_with_the_envelope() {
1438+
for (scenario, doctor_status, exit_code, success, envelope_status) in [
1439+
("healthy", "ok", 0, true, "succeeded"),
1440+
("degraded", "warn", 0, true, "succeeded"),
1441+
("disconnected_recoverable", "error", 1, false, "failed"),
1442+
("terminal_error", "error", 1, false, "failed"),
1443+
] {
1444+
let response = cli_response_for_json_result_for_identity(
1445+
&Ok(json!({
1446+
"command": "runner.doctor",
1447+
"status": doctor_status,
1448+
"checks": [{ "id": "daemon.recovery", "status": doctor_status }],
1449+
})),
1450+
exit_code,
1451+
&CommandIdentity::with_operation("runner", "doctor"),
1452+
None,
1453+
);
1454+
let value = serde_json::to_value(response).expect("serialize response");
1455+
1456+
assert_eq!(value["success"], success, "{scenario}");
1457+
assert_eq!(value["exit_code"], exit_code, "{scenario}");
1458+
assert_eq!(value["status"], envelope_status, "{scenario}");
1459+
assert_eq!(value["data"]["status"], doctor_status, "{scenario}");
1460+
}
1461+
}
1462+
14361463
fn release_failure_payload(step_id: &str, step_type: &str) -> Value {
14371464
json!({
14381465
"command": "release",

docs/commands/runner.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ replace the session, or tear down its tunnel. Use `local`,
179179
`localhost`, or `self` to inspect this machine without creating a runner record.
180180
The JSON payload uses `command: "runner.doctor"` and includes `runner_id`,
181181
`status`, `capabilities`, and warning/error details when a capability probe fails.
182+
`status: "error"` means the runner is not ready and exits with status `1`; `ok`
183+
and non-blocking `warn` reports exit `0`. The command has no report-only mode.
182184

183185
Use `doctor` before `connect` when you need to know whether Homeboy, Git, SSH,
184186
and the configured workspace root are usable on the target machine.

0 commit comments

Comments
 (0)