Keep watching a run across a forge restart

bdd2439 · Gabriel Morell · 2026-09-19 04:16

4 files +34 -1
Message
{commit_body(@commit)}

Files changed

modified cli/src/commands/ci.rs
+34 −1
@@ -12,6 +12,11 @@ use crate::output::{
12 12 status_mark, status_tone, truncate,
13 13 };
14 14
15 +/// How many consecutive polling failures end a watch. At the default
16 +/// interval that is about a minute of silence, which outlasts a restart
17 +/// without hiding a forge that has genuinely gone away.
18 +const MAX_WATCH_FAILURES: u32 = 12;
19 +
15 20 pub async fn run(ctx: &Ctx, command: &CiCommand) -> Result<()> {
16 21 let repo = ctx.repo()?;
17 22 match command {
@@ -308,9 +313,37 @@ async fn watch(ctx: &Ctx, repo: &RepoRef, number: Option<i64>, interval: u64) ->
308 313 let number = resolve_number(ctx, repo, number).await?;
309 314 let interval = Duration::from_secs(interval.max(1));
310 315 let mut last_line = String::new();
316 + let mut failures = 0;
311 317
312 318 loop {
313 − let res = ctx.api.run(repo, number).await?;
319 + // A watch outlives things the forge does to itself. Deploying it
320 + // mid-run answers with 502s for a few seconds, and giving up
321 + // then is the opposite of what watching is for — so transient
322 + // errors are reported once and retried, and only a run of them
323 + // ends the watch.
324 + let res = match ctx.api.run(repo, number).await {
325 + Ok(res) => {
326 + if failures > 0 {
327 + note("reconnected");
328 + failures = 0;
329 + }
330 + res
331 + }
332 + Err(e) => {
333 + failures += 1;
334 + if failures >= MAX_WATCH_FAILURES {
335 + return Err(e.context(format!(
336 + "giving up after {failures} consecutive failures watching run #{number}"
337 + )));
338 + }
339 + if failures == 1 {
340 + note(&format!("{e:#} — retrying"));
341 + }
342 + tokio::time::sleep(interval).await;
343 + continue;
344 + }
345 + };
346 +
314 347 let run = &res.run;
315 348 let state = effective_status(&run.status, run.conclusion.as_deref());
316 349

Parents: 5c5c51d