Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller: record the review classifier's runs and list them

Every run is recorded when it ends: a try-out when its reply settles it,
a requested review once its review is posted or the post fails, and a
request turned away by a full queue once its comment is posted. A
duplicate request that starts nothing is not recorded. The newest 100
are one JSON array under `recent` in a new controller-owned KV bucket,
`review-classifier-runs`, written with compare-and-set as
`schedule-definitions` is (five attempts). A failed write is logged; the
review is already posted by then.

Each recorded run is also one info event, "review classifier run
finished", with the record's fields as tracing fields. The controller
logs natively to the journal, so they reach VictoriaLogs as F_<NAME>.

GET /api/review-classifier/runs returns the list, newest first, gated
by the gateway like the try-out GET. post_classifier_review now returns
the posted review's html_url, which the record keeps as review_url.

Closes #5153, part of #5096.
This commit is contained in:
atlas 2026-10-12 19:23:28 +02:00
commit 2fc0166427
14 changed files with 1138 additions and 66 deletions

View file

@ -8,7 +8,8 @@
//! the review classifier on [`SUBJECT`] and answers 202 with an id at once:
//! one model call over a big diff can outlast the 60 seconds the gateway
//! holds a request open. The reply is kept in memory, where the GET reads it
//! until [`RESULT_TTL`] after it arrived. Nothing reaches the forge.
//! until [`RESULT_TTL`] after it arrived, and recorded in [`runs`]. Nothing
//! reaches the forge.
//!
//! The rules are read with the same token on every request, with no cache.
//!
@ -38,6 +39,7 @@ use crate::forge::{self, DefaultBranchFiles, PullForReview};
use crate::{AppState, error_problem};
pub(crate) mod review;
pub(crate) mod runs;
/// How long a finished run's result stays readable.
pub(crate) const RESULT_TTL: Duration = Duration::from_hours(1);
@ -342,6 +344,7 @@ pub(crate) async fn post_try_out(
&state.try_outs,
&*fake,
(*fake).clone(),
state.runs.clone(),
operator,
run,
))
@ -366,7 +369,16 @@ pub(crate) async fn post_try_out(
&swarm_queue_client::chain(&e),
)
})?;
Box::pin(start(&state.try_outs, forge.as_ref(), queue, operator, run)).await
let runs = state.runs.clone();
Box::pin(start(
&state.try_outs,
forge.as_ref(),
queue,
runs,
operator,
run,
))
.await
}
/// Read a run's result.
@ -606,14 +618,16 @@ fn operator(headers: &HeaderMap) -> Option<String> {
}
/// Read the PR, record the run and send it; the reply settles the run when
/// it arrives.
/// it arrives, and the settled run goes into `runs`.
async fn start(
try_outs: &TryOuts,
pulls: &impl PullSource,
sink: impl RunSink,
runs: runs::Runs,
operator: String,
run: Run,
) -> Result<(StatusCode, Json<TryOutAccepted>), ProblemDetails> {
let started_unix = crate::agent_renewal::unix_now();
let repo = format!("{}/{}", run.owner, run.name);
let pull =
forge::within_forge_deadline("review try-out", pulls.pull(&run.owner, &run.name, run.pr))
@ -641,11 +655,38 @@ async fn start(
)
})?;
let id = mint_id().map_err(|e| error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e))?;
try_outs.insert(id.clone(), pull.head_sha);
let (runs, settle_id) = (try_outs.clone(), id.clone());
try_outs.insert(id.clone(), pull.head_sha.clone());
let try_outs = try_outs.clone();
let (settle_id, repo, pr, requester) = (
id.clone(),
request.repo.clone(),
request.pr,
request.operator.clone(),
);
tokio::spawn(async move {
let reply = sink.run(payload).await;
runs.settle(&settle_id, reply);
let (rows, overall) = runs::answer(&reply);
let (outcome, detail) = match &reply {
Reply::Done { .. } => (runs::RunOutcome::Done, None),
Reply::Failed { error } => (runs::RunOutcome::Failed, Some(error.clone())),
};
try_outs.settle(&settle_id, reply);
let record = runs::RunRecord {
id: settle_id,
trigger: runs::Trigger::TryOut,
repo,
pr,
requester,
head_sha: Some(pull.head_sha),
started_unix,
finished_unix: crate::agent_renewal::unix_now(),
outcome,
detail,
rows,
overall,
review_url: None,
};
runs.record(record).await;
});
tracing::info!(%id, repo = %request.repo, pr = request.pr, "review try-out started");
Ok((StatusCode::ACCEPTED, Json(TryOutAccepted { id })))
@ -877,6 +918,48 @@ pub(crate) mod tests {
);
}
#[tokio::test]
async fn a_settled_try_out_is_recorded_as_done_or_failed() {
let failed = Reply::Failed {
error: "no API key".to_owned(),
};
for (reply, outcome, detail) in [
(done(), runs::RunOutcome::Done, None),
(failed, runs::RunOutcome::Failed, Some("no API key")),
] {
let doc = Arc::new(runs::tests::FakeDoc::default());
let (mut state, _) = crate::tests::state_with_roster();
state.try_outs.fake = Some(Arc::new(Fake {
reply: reply.clone(),
files: Vec::new(),
}));
state.runs = runs::Runs::Fake(Arc::clone(&doc));
let app = crate::build_app(state);
let (_, accepted) = post(&app, body("o/r", 7)).await;
let id = accepted["id"].as_str().expect("an id");
settled(&app, id).await;
let mut recorded = Vec::new();
for _ in 0..100 {
recorded = doc.runs();
if !recorded.is_empty() {
break;
}
tokio::task::yield_now().await;
}
assert_eq!(recorded.len(), 1, "{outcome:?}");
let run = &recorded[0];
assert_eq!(run.id, id);
assert_eq!(run.trigger, runs::Trigger::TryOut);
assert_eq!((run.repo.as_str(), run.pr), ("o/r", 7));
assert_eq!(run.requester, "alice");
assert_eq!(run.head_sha.as_deref(), Some("abc123"));
assert_eq!(run.outcome, outcome);
assert_eq!(run.detail.as_deref(), detail);
assert_eq!((run.rows.clone(), run.overall), runs::answer(&reply));
assert!(run.started_unix <= run.finished_unix);
}
}
#[tokio::test]
async fn a_bad_body_is_400() {
let app = app(done());