Skip to content

Commit b8a488e

Browse files
author
Chris Huber
committed
Merge main into fix/11933-provider-terminalization
AI assistance: OpenAI GPT-5.6 Sol via OpenCode preserved terminal provider detection while integrating current owner-PID activity sampling. Chris Huber remains responsible for every line.
2 parents dbf437b + 7dad759 commit b8a488e

117 files changed

Lines changed: 7060 additions & 718 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/ci-capacity-admission.sh

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
config="${CI_CAPACITY_CONFIG:-.github/ci-capacity.json}"
6+
jq -e '
7+
. as $config
8+
| .schema == "homeboy/ci-capacity/v1"
9+
and (.test_shards | type == "number" and floor == . and . >= 1)
10+
and (.queue_delay_slo_seconds.window_days | type == "number" and . >= 1)
11+
and (.queue_delay_slo_seconds.p95 | type == "number" and . > 0)
12+
and ($config.queue_delay_slo_seconds.p99 | type == "number" and . >= $config.queue_delay_slo_seconds.p95)
13+
and (.execution_slo_seconds.test_shard_p95 | type == "number" and . > 0)
14+
and ($config.execution_slo_seconds.required_critical_path_p95 | type == "number" and . >= $config.execution_slo_seconds.test_shard_p95)
15+
' "${config}" >/dev/null
16+
17+
shards="$(jq -r '.test_shards' "${config}")"
18+
queue_p95="$(jq -r '.queue_delay_slo_seconds.p95' "${config}")"
19+
queue_p99="$(jq -r '.queue_delay_slo_seconds.p99' "${config}")"
20+
window_days="$(jq -r '.queue_delay_slo_seconds.window_days' "${config}")"
21+
shard_p95="$(jq -r '.execution_slo_seconds.test_shard_p95' "${config}")"
22+
critical_p95="$(jq -r '.execution_slo_seconds.required_critical_path_p95' "${config}")"
23+
24+
if [ -n "${GITHUB_OUTPUT:-}" ]; then
25+
{
26+
echo "test-shards=${shards}"
27+
echo "queue-delay-p95-seconds=${queue_p95}"
28+
echo "queue-delay-p99-seconds=${queue_p99}"
29+
echo "test-shard-p95-seconds=${shard_p95}"
30+
echo "required-critical-path-p95-seconds=${critical_p95}"
31+
} >> "${GITHUB_OUTPUT}"
32+
fi
33+
34+
cat <<EOF >> "${GITHUB_STEP_SUMMARY:?GITHUB_STEP_SUMMARY is required}"
35+
## CI capacity admission
36+
37+
| Field | Value |
38+
| --- | --- |
39+
| State | admitted |
40+
| Requested deterministic Test shards | ${shards} |
41+
| Admitted Test shards | ${shards} |
42+
| Deferred Test shards | 0 |
43+
| Capacity budget | ${shards} concurrent Test shards per run |
44+
| Queue-delay SLO | ${window_days}-day p95 <= ${queue_p95}s; p99 <= ${queue_p99}s |
45+
| Execution SLO | Test shard p95 <= ${shard_p95}s; required-check critical path p95 <= ${critical_p95}s |
46+
47+
This repository admits only the configured shard budget. GitHub-hosted runner availability remains separately visible in the timing evidence job.
48+
EOF

.github/ci-capacity-evidence.sh

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
config="${CI_CAPACITY_CONFIG:-.github/ci-capacity.json}"
6+
run="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}")"
7+
jobs="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs?per_page=100" | jq -s '{jobs: map(.jobs[]) }')"
8+
required='["homeboy / Required Gates Declaration","homeboy / Workspace Tests Compile","homeboy / Windows Compile","homeboy / Rustfmt","homeboy / Audit","homeboy / Lint","homeboy / Test"]'
9+
10+
jq -n --argjson run "${run}" --argjson jobs "${jobs}" --argjson required "${required}" --slurpfile config "${config}" '
11+
def seconds($from; $to): (($to | fromdateiso8601) - ($from | fromdateiso8601));
12+
($jobs.jobs | map(select(.started_at != null and .completed_at != null))) as $completed
13+
| ($completed | map({name, queue_delay_seconds: seconds(.created_at; .started_at), execution_seconds: seconds(.started_at; .completed_at)})) as $timings
14+
| ($completed | map(select(.name as $name | $required | index($name))) | max_by(.completed_at)) as $critical
15+
| {
16+
schema: "homeboy/ci-capacity-evidence/v1",
17+
run_id: $run.id,
18+
workflow_created_at: $run.created_at,
19+
admission: {state: "admitted", configured_test_shards: $config[0].test_shards, deferred_test_shards: 0},
20+
jobs: $timings,
21+
required_critical_path: (if $critical == null then null else {
22+
terminal_job: $critical.name,
23+
terminal_at: $critical.completed_at,
24+
created_to_terminal_seconds: seconds($run.created_at; $critical.completed_at)
25+
} end),
26+
slo: $config[0]
27+
}
28+
' > ci-capacity-evidence.json
29+
30+
{
31+
echo "## CI queue and execution evidence"
32+
echo
33+
echo "The admission decision is configuration-backed. GitHub exposes no separate scheduler admission timestamp, so each job's \\`created -> runner started\\` value is the observed hosted-runner queue delay."
34+
echo
35+
echo '| Job | Queue delay (s) | Execution (s) |'
36+
echo '| --- | ---: | ---: |'
37+
jq -r '.jobs[] | "| \(.name) | \(.queue_delay_seconds) | \(.execution_seconds) |"' ci-capacity-evidence.json
38+
echo
39+
jq -r 'if .required_critical_path == null then "Required critical path was not terminal; see job states above." else "Required critical path: \(.required_critical_path.terminal_job), created -> terminal \(.required_critical_path.created_to_terminal_seconds)s." end' ci-capacity-evidence.json
40+
echo
41+
echo "Rolling SLO evaluation requires seven days of retained run evidence; this run publishes its raw timing record for that aggregation."
42+
} >> "${GITHUB_STEP_SUMMARY:?GITHUB_STEP_SUMMARY is required}"

.github/ci-capacity.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"schema": "homeboy/ci-capacity/v1",
3+
"test_shards": 4,
4+
"queue_delay_slo_seconds": {
5+
"window_days": 7,
6+
"p95": 300,
7+
"p99": 600
8+
},
9+
"execution_slo_seconds": {
10+
"test_shard_p95": 1800,
11+
"required_critical_path_p95": 2100
12+
}
13+
}

.github/workflows/ci.yml

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,24 @@ jobs:
4444
GITHUB_EVENT_ACTION: ${{ github.event.action }}
4545
run: bash .github/ci-pr-state.sh
4646

47+
# This repository owns how much work it asks the reusable workflow to admit.
48+
# The action owns deterministic shard planning and aggregation, not this
49+
# repository's GitHub-hosted runner budget.
50+
ci-capacity-admission:
51+
name: homeboy / CI Capacity Admission
52+
needs: pr-state
53+
if: ${{ needs.pr-state.outputs.active == 'true' }}
54+
runs-on: ubuntu-latest
55+
outputs:
56+
test-shards: ${{ steps.admit.outputs.test-shards }}
57+
steps:
58+
- uses: actions/checkout@v6
59+
with:
60+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
61+
- id: admit
62+
name: Admit configured Test capacity
63+
run: bash .github/ci-capacity-admission.sh
64+
4765
# Named for what a green tick here actually proves: the DECLARATION is intact.
4866
# It used to be called "Required Gates Policy" and run `--local`, which reads
4967
# as "GitHub requires these checks" while only proving "ci.yml emits these
@@ -202,7 +220,7 @@ jobs:
202220
# ambiguous source changes fail closed or use Homeboy's full-scope fallback.
203221
homeboy:
204222
name: homeboy
205-
needs: pr-state
223+
needs: [pr-state, ci-capacity-admission]
206224
if: ${{ needs.pr-state.outputs.active == 'true' }}
207225
uses: Extra-Chill/homeboy-action/.github/workflows/ci.yml@v2
208226
with:
@@ -223,9 +241,29 @@ jobs:
223241
scope: auto
224242
differential-gating: 'false'
225243
baseline-commands: none
226-
test-shards: '16'
244+
test-shards: ${{ needs.ci-capacity-admission.outputs.test-shards }}
227245
execution-timeout-seconds: '1800'
228246
test-timeout-seconds: '1500'
229247
comment-section-key: test
230248
comment-section-title: Test
231249
secrets: inherit
250+
251+
ci-capacity-evidence:
252+
name: homeboy / CI Capacity Evidence
253+
needs: [required-gates-declaration, workspace-tests-compile, warning-clean, windows-compile, rustfmt, homeboy-fast, homeboy]
254+
if: ${{ always() }}
255+
runs-on: ubuntu-latest
256+
steps:
257+
- uses: actions/checkout@v6
258+
with:
259+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
260+
- name: Publish queue, execution, and critical-path timing evidence
261+
env:
262+
GH_TOKEN: ${{ github.token }}
263+
run: bash .github/ci-capacity-evidence.sh
264+
- uses: actions/upload-artifact@v7
265+
if: always()
266+
with:
267+
name: homeboy-ci-capacity-evidence-${{ github.run_attempt }}
268+
path: ci-capacity-evidence.json
269+
if-no-files-found: warn

.github/workflows/release.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1454,6 +1454,52 @@ jobs:
14541454
fi
14551455
echo "::notice::Release ${RELEASE_TAG} contains every planned asset."
14561456
1457+
# A complete remote inventory proves that every archive was uploaded. It does
1458+
# not prove that the consumer resolver selects this release rather than
1459+
# falling back to an older installable one. Exercise the public action on
1460+
# Linux with no source or binary override, which is the same resolution path
1461+
# used by downstream CI consumers.
1462+
verify-linux-consumer:
1463+
name: Verify Linux consumer
1464+
needs:
1465+
- prepare
1466+
- verify-published
1467+
if: ${{ always() && needs.prepare.outputs.prepared == 'true' && needs.prepare.outputs['release-tag'] != '' && needs.verify-published.result == 'success' }}
1468+
runs-on: ubuntu-22.04
1469+
permissions:
1470+
contents: read
1471+
steps:
1472+
- uses: actions/checkout@v6
1473+
with:
1474+
ref: ${{ needs.prepare.outputs['release-tag'] }}
1475+
persist-credentials: false
1476+
1477+
- name: Resolve latest release and run Homeboy Action
1478+
uses: Extra-Chill/homeboy-action@v2
1479+
with:
1480+
# Deliberately omit local-build and supplied-executable inputs: the
1481+
# action must install the Linux archive a downstream consumer
1482+
# resolves from latest.
1483+
commands: review lint
1484+
expected-commands: review lint
1485+
1486+
- name: Verify Homeboy Action installed the published version
1487+
env:
1488+
RELEASE_TAG: ${{ needs.prepare.outputs['release-tag'] }}
1489+
run: |
1490+
set -euo pipefail
1491+
EXPECTED_VERSION="${RELEASE_TAG#v}"
1492+
ACTUAL_VERSION="$(homeboy --version)"
1493+
case "${ACTUAL_VERSION}" in
1494+
*"${EXPECTED_VERSION}"*)
1495+
echo "::notice::Linux consumer resolved ${ACTUAL_VERSION} for ${RELEASE_TAG}"
1496+
;;
1497+
*)
1498+
echo "::error::Linux consumer resolved '${ACTUAL_VERSION}', not the release just published (${RELEASE_TAG}). The action must not fall back to an older release." >&2
1499+
exit 1
1500+
;;
1501+
esac
1502+
14571503
announce:
14581504
needs:
14591505
- plan
@@ -1482,6 +1528,7 @@ jobs:
14821528
- build-local-artifacts
14831529
- build-global-artifacts
14841530
- host
1531+
- verify-linux-consumer
14851532
- verify-published
14861533
- announce
14871534
if: ${{ always() && github.event_name == 'push' && needs.check.outputs.should-release == 'true' && contains(toJson(needs), '"result":"failure"') }}
@@ -1553,6 +1600,7 @@ jobs:
15531600
- build-local-artifacts
15541601
- build-global-artifacts
15551602
- host
1603+
- verify-linux-consumer
15561604
- verify-published
15571605
- announce
15581606
if: ${{ always() && github.event_name == 'push' && needs.check.outputs.should-release == 'true' && !contains(toJson(needs), '"result":"failure"') && !contains(toJson(needs), '"result":"cancelled"') }}

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/homeboy-agents/src/agent_task_cook_loop.rs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ use crate::agent_task::{
66
AgentTaskRequest, AgentTaskSourceRef, AgentTaskWorkspaceMode, AGENT_TASK_REQUEST_SCHEMA,
77
};
88
use crate::agent_task_gate::{
9-
text_tail, AgentTaskGateDiagnosticProducer, AgentTaskGateDiagnosticRecord, AgentTaskGateReport,
10-
AgentTaskGateRevealPolicy, AgentTaskGateStatus, AgentTaskGateVisibility,
9+
text_tail, AgentTaskGateDiagnosticProducer, AgentTaskGateDiagnosticRecord,
10+
AgentTaskGateFailureClassification, AgentTaskGateReport, AgentTaskGateRevealPolicy,
11+
AgentTaskGateStatus, AgentTaskGateVisibility,
1112
};
1213
use crate::agent_task_promotion::{AgentTaskPromotionReport, AgentTaskPromotionStatus};
1314
use crate::agent_task_review_dossier::AiFilledReviewForm;
@@ -175,6 +176,8 @@ pub struct AgentTaskCookLoopGateFailure {
175176
#[serde(default, skip_serializing_if = "String::is_empty")]
176177
pub command: String,
177178
pub exit_code: i32,
179+
#[serde(default)]
180+
pub classification: AgentTaskGateFailureClassification,
178181
#[serde(default, skip_serializing_if = "String::is_empty")]
179182
pub stdout_tail: String,
180183
#[serde(default, skip_serializing_if = "String::is_empty")]
@@ -249,6 +252,9 @@ pub fn evaluate_cook_loop(options: AgentTaskCookLoopOptions) -> AgentTaskCookLoo
249252
apply_failure_progression_to_quality(&mut quality, &failure_progression);
250253
let should_retry = options.promotion_report.status == AgentTaskPromotionStatus::GateFailed
251254
&& !failed_gates.is_empty()
255+
&& failed_gates
256+
.iter()
257+
.all(|gate| gate.classification == AgentTaskGateFailureClassification::CandidateCode)
252258
&& !baseline_red
253259
&& retry_budget_remaining > 0;
254260
// Deterministic gates take precedence: a red gate must be fixed before the
@@ -608,6 +614,11 @@ fn gate_failure(
608614
reveal_policy: gate.reveal_policy,
609615
command,
610616
exit_code: gate.exit_code,
617+
classification: gate
618+
.failure_evidence
619+
.as_ref()
620+
.map(|evidence| evidence.classification)
621+
.unwrap_or_default(),
611622
stdout_tail,
612623
stderr_tail,
613624
summary,
@@ -641,6 +652,7 @@ fn agent_visible_gate_failure(
641652
reveal_policy: failure.reveal_policy,
642653
command: String::new(),
643654
exit_code: failure.exit_code,
655+
classification: failure.classification,
644656
stdout_tail: String::new(),
645657
stderr_tail: String::new(),
646658
summary: format!(
@@ -657,6 +669,7 @@ fn agent_visible_gate_failure(
657669
reveal_policy: failure.reveal_policy,
658670
command: String::new(),
659671
exit_code: failure.exit_code,
672+
classification: failure.classification,
660673
stdout_tail: String::new(),
661674
stderr_tail: String::new(),
662675
summary: "private deterministic gate failed; evidence redacted".to_string(),
@@ -670,6 +683,7 @@ fn agent_visible_gate_failure(
670683
reveal_policy: failure.reveal_policy,
671684
command: String::new(),
672685
exit_code: failure.exit_code,
686+
classification: failure.classification,
673687
stdout_tail: String::new(),
674688
stderr_tail: String::new(),
675689
summary: "private deterministic gate failed".to_string(),
@@ -981,6 +995,33 @@ mod tests {
981995
assert_eq!(request.policy.write, "artifacts_only");
982996
}
983997

998+
#[test]
999+
fn gate_declaration_failure_never_creates_code_remediation() {
1000+
let mut gate = failed_gate();
1001+
gate.failure_evidence
1002+
.as_mut()
1003+
.expect("failure evidence")
1004+
.classification = AgentTaskGateFailureClassification::GateDeclaration;
1005+
let report = evaluate_cook_loop(AgentTaskCookLoopOptions {
1006+
source_request: source_request(),
1007+
promotion_report: promotion_report(AgentTaskPromotionStatus::GateFailed, vec![gate]),
1008+
attempt: 1,
1009+
max_attempts: 3,
1010+
source_run_id: Some("run-declaration".to_string()),
1011+
current_diff: String::new(),
1012+
require_review_form: false,
1013+
review_form: None,
1014+
metadata: Value::Null,
1015+
});
1016+
1017+
assert_eq!(report.status, AgentTaskCookLoopStatus::RetriesExhausted);
1018+
assert!(report.follow_up_request.is_none());
1019+
assert_eq!(
1020+
report.failed_gates[0].classification,
1021+
AgentTaskGateFailureClassification::GateDeclaration
1022+
);
1023+
}
1024+
9841025
#[test]
9851026
fn red_gate_preserves_executor_provider_configuration() {
9861027
let mut source = source_request();
@@ -1381,6 +1422,7 @@ mod tests {
13811422
"producer output is opaque",
13821423
String::new(),
13831424
Some(AgentTaskGateFailureEvidence {
1425+
classification: AgentTaskGateFailureClassification::CandidateCode,
13841426
summary: "producer reported a failure".to_string(),
13851427
command: "opaque-gate".to_string(),
13861428
exit_code: 1,
@@ -1451,6 +1493,7 @@ mod tests {
14511493
reveal_policy: AgentTaskGateRevealPolicy::FullEvidence,
14521494
command: "opaque-gate".to_string(),
14531495
exit_code: 101,
1496+
classification: AgentTaskGateFailureClassification::CandidateCode,
14541497
stdout_tail: String::new(),
14551498
stderr_tail: String::new(),
14561499
summary: String::new(),
@@ -1898,6 +1941,7 @@ mod tests {
18981941
"running tests",
18991942
"boom",
19001943
Some(AgentTaskGateFailureEvidence {
1944+
classification: AgentTaskGateFailureClassification::CandidateCode,
19011945
summary: "opaque gate failed".to_string(),
19021946
command: "opaque-gate".to_string(),
19031947
exit_code: 101,
@@ -1942,6 +1986,7 @@ mod tests {
19421986
"secret fixture mismatch",
19431987
"private evaluator stack trace",
19441988
Some(AgentTaskGateFailureEvidence {
1989+
classification: AgentTaskGateFailureClassification::CandidateCode,
19451990
summary: "secret fixture mismatch on randomized private corpus".to_string(),
19461991
command: "./hidden-heldout-check --fixture secret".to_string(),
19471992
exit_code: 7,

0 commit comments

Comments
 (0)