OXYGENHUB

Runners decline, and the queue says why a job waits 4d2e44d79b03

POST /api/v1/flow/jobs/{id}/decline (OxygenFlow spec §11.5): the job is
queued again and kept from that runner while its profile (tiers, images,
architecture, labels, capacity, hashed at each claim) stays the same, ten
minutes at most; three different runners declining it end it as
unplaceable with their reasons, never a failed check. A placement pass
every 15 s writes why each ready queued job no runner can take waits
(spec §11.4), shown on the run page and in the API (FlowJob.waiting); a
runner's page shows its tiers and its declines. Migration 0043.
refs oxygenflow#36
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-05 00:42 UTC
Signature
✔ signed by Andy McCall
Hash
4d2e44d79b03ab939a3b69c02fa7115265089c072018123c18fba764865fe298
Depends on
25091e5c3bba 348f8c4baccf 380996db84e5 42fa95540468 62ceb2eec18c 749d9a5fcd9f 76bc5a058351 76f262e1a666 7f5f6c056d8f 8a3334b7a364 90326c9601f9 a3c8288b0141 b0612e7dce30 b5668108487f b64e2537d702 b66e70b7b6c7 b6b7e4cf6b24 bf1ab3458db1 bf3d07541368 c4c87f481d05 cc19697f8871 d23464c0ac60 efd29a0033e8 fd3db644df22
Depended on by
041a945f55f8 951aed50c2f5 8501fd5815b9 56b8dbe66974 ab4df6aae179 794ff94f194a 858eca0d03c4 e57765dee61a 58cbf42447bc e9b9619cd857 5d90e0e045e2 417184c73f4e d9b3b84605a4 71e65fcf95ab 501d8b268dd4 44a3d2ebaddd 2fb70d019851 92a797b20912 67040b158ae2 554ccacfc123 1f78655a0466
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
43 atoms, 1 tree operation, 14 files

Browse the tree at this change

added crates/hub/migrations/0043_flow_declines.sql +19 −0
1 +
-- Placement you can see (OxygenFlow spec §11.4–11.5, oxygenflow#36): what
2 +
-- each runner offers at its last claim, the jobs runners handed back with
3 +
-- a reason, and why a queued job waits.
4 +
ALTER TABLE flow_runners ADD COLUMN tiers TEXT NOT NULL DEFAULT '';
5 +
-- JSON array of image hashes; empty when the runner did not say.
6 +
ALTER TABLE flow_runners ADD COLUMN images TEXT NOT NULL DEFAULT '';
7 +
-- BLAKE3 of what it offers: a decline holds until this changes.
8 +
ALTER TABLE flow_runners ADD COLUMN profile TEXT NOT NULL DEFAULT '';
9 +
ALTER TABLE flow_jobs ADD COLUMN waiting TEXT NOT NULL DEFAULT '';
10 +
CREATE TABLE flow_declines (
11 +
    job_id     INTEGER NOT NULL REFERENCES flow_jobs(id) ON DELETE CASCADE,
12 +
    runner_id  INTEGER NOT NULL,
13 +
    runner     TEXT NOT NULL,
14 +
    profile    TEXT NOT NULL,
15 +
    reason     TEXT NOT NULL,
16 +
    ms         INTEGER NOT NULL,
17 +
    PRIMARY KEY (job_id, runner_id)
18 +
);
19 +
CREATE INDEX flow_declines_runner ON flow_declines (runner_id, ms);
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Runners decline, and the queue says why a job waits (oxygenflow#36, OxygenFlow spec §11.4–11.5).** A runner can hand back a job it cannot run with a reason. The job is queued again and not offered to that runner while what it offers stays the same, for ten minutes at most. After three different runners decline it, the job ends as unplaceable with their reasons. Every 15 seconds the hub works out why each ready job no runner can take is waiting, from each runner's last claim: no runner online, a missing tier, the wrong architecture, an image it does not hold, a label, memory or CPUs, a scope, or a decline. The run page and the API show the reason, and a runner's page shows its tiers and what it declined.
8 +
7 9
- **Assistants through the API and `oh` (§13.4).** List, add or change, and remove a repository's, an organisation's or the site's assistants, and read or set their prompts: `/repos/{o}/{n}/assistants`, `/orgs/{org}/assistants`, `/admin/assistants`; `oh assistant list|add|remove|prompt` with `--org` or `--site`. Tokens are write-only and read from an environment variable by `oh`.
8 10
9 11
- **Adding an assistant picks from what the provider offers (#109).** A provider list fills the name, endpoint and API (with *Other* for anything else), **List models** fetches the endpoint's own models into the Model field, and a model the endpoint does not list is refused on save with ones it does. Administration is a sectioned page with a left-hand menu, its audit log paged (#110).
…
117 unchanged lines
modified crates/hub-api/src/lib.rs +4 −0
…
550 unchanged lines
551 551
    pub finished_ms: Option<i64>,
552 552
    /// Log lines stored so far.
553 553
    pub log_lines: i64,
554 +
    /// Why a queued job no runner can take waits (OxygenFlow spec §11.4);
555 +
    /// empty when it only waits its turn.
556 +
    #[serde(default, skip_serializing_if = "String::is_empty")]
557 +
    pub waiting: String,
554 558
}
555 559
556 560
/// A line of a job's log.
…
583 unchanged lines
modified crates/hub-api/ts/index.d.ts +43 −6
…
47 unchanged lines
48 48
  status: "invalid";
49 49
};
50 50
51 +
export type Via = {
52 +
  expires_ms?: number | null;
53 +
  key: string;
54 +
  name: string;
55 +
  revoked: boolean;
56 +
  scope: string;
57 +
};
58 +
51 59
export type ChangeSummary = {
52 60
  author: Author;
53 61
  deps: string[];
…
1 unchanged lines
55 63
  head: boolean;
56 64
  message: string;
57 65
  signature: Signature;
66 +
  via?: Via | null;
58 67
  when_ms: number;
59 68
};
60 69
…
9 unchanged lines
70 79
  sealed: boolean;
71 80
};
72 81
82 +
export type Label = {
83 +
  colour: string;
84 +
  description: string;
85 +
  name: string;
86 +
};
87 +
73 88
export type Landed = {
74 89
  by: string;
75 90
  via: string;
…
23 unchanged lines
99 114
  changes: ChangeSummary[];
100 115
  conflicted: boolean;
101 116
  created_ms: number;
117 +
  labels?: Label[];
102 118
  landable: boolean;
103 119
  landed?: Landed | null;
104 120
  number: number;
…
8 unchanged lines
113 129
  title: string;
114 130
  updated_ms: number;
115 131
  url: string;
116 -
};
117 -
118 -
export type Label = {
119 -
  colour: string;
120 -
  description: string;
121 -
  name: string;
122 132
};
123 133
124 134
export type Issue = {
…
24 unchanged lines
149 159
  title: string;
150 160
};
151 161
162 +
export type NewMilestone = {
163 +
  description?: string;
164 +
  due?: string | null;
165 +
  title: string;
166 +
};
167 +
168 +
export type MilestoneEdit = {
169 +
  description?: string | null;
170 +
  due?: string | null;
171 +
  state?: string | null;
172 +
  title?: string | null;
173 +
};
174 +
152 175
export type Atom = {
153 176
  change: string;
154 177
  index: number;
…
39 unchanged lines
194 217
  id: number;
195 218
  sealed: boolean;
196 219
  verdict: string;
220 +
  via?: string;
197 221
};
198 222
199 223
export type TreeEntry = {
…
155 unchanged lines
355 379
  body?: string | null;
356 380
  labels?: string[] | null;
357 381
  milestone?: string | null;
382 +
  title?: string | null;
383 +
};
384 +
385 +
export type ReleaseEdit = {
386 +
  notes?: string | null;
387 +
  prerelease?: boolean | null;
388 +
  title?: string | null;
389 +
};
390 +
391 +
export type ProposalEdit = {
392 +
  body?: string | null;
393 +
  labels?: string[] | null;
358 394
  title?: string | null;
359 395
};
360 396
…
144 unchanged lines
505 541
  runner: string;
506 542
  started_ms?: number | null;
507 543
  status: string;
544 +
  waiting?: string;
508 545
};
509 546
510 547
export type FlowRun = {
…
30 unchanged lines
modified crates/hub/src/api/mod.rs +2 −0
…
37 unchanged lines
38 38
        .route("/flow/runners/register", post(crate::flow::register))
39 39
        .route("/flow/jobs/{id}/log", post(crate::flow::log))
40 40
        .route("/flow/jobs/{id}/finish", post(crate::flow::finish))
41 +
        .route("/flow/jobs/{id}/decline", post(crate::flow::decline))
41 42
        .route(
42 43
            "/flow/jobs/{id}/artifacts/{name}",
43 44
            post(crate::flow::artifact).layer(axum::extract::DefaultBodyLimit::max(crate::flow::MAX_ARTIFACT_BYTES + (1 << 20))),
…
1793 unchanged lines
1837 1838
                started_ms: j.started_ms,
1838 1839
                finished_ms: j.finished_ms,
1839 1840
                log_lines: j.log_count,
1841 +
                waiting: j.waiting,
1840 1842
            })
1841 1843
            .collect(),
1842 1844
    }
…
516 unchanged lines
modified crates/hub/src/db/flow.rs +163 −2
…
58 unchanged lines
59 59
    }
60 60
}
61 61
62 -
#[derive(Clone, Debug, FromRow)]
62 +
#[derive(Clone, Debug, Default, FromRow)]
63 63
pub struct Job {
64 64
    pub id: i64,
65 65
    pub run_id: i64,
…
21 unchanged lines
87 87
    pub receipt_signer: String,
88 88
    /// Why a signed receipt was not kept.
89 89
    pub receipt_note: String,
90 +
    /// Why a queued job no runner can take waits (spec §11.4), from the
91 +
    /// last placement pass; empty when it is only waiting its turn.
92 +
    pub waiting: String,
90 93
}
91 94
92 95
impl Job {
…
94 unchanged lines
187 190
    /// The runner labels this job needs.
188 191
    pub fn needs(&self) -> Vec<String> {
189 192
        labels_in(&self.spec, &self.sandbox)
193 +
    }
194 +
195 +
    /// The architecture the job's sandbox asks for (OxygenFlow spec §11.3).
196 +
    pub fn arch(&self) -> Option<String> {
197 +
        arch_in(&self.sandbox)
190 198
    }
191 199
}
192 200
…
132 unchanged lines
325 333
    pub arch: String,
326 334
    /// Each image's architecture as runners have reported it (`flow_images`).
327 335
    pub image_archs: std::collections::HashMap<String, String>,
336 +
    /// Jobs this runner declined, not to be offered to it again (spec §11.5).
337 +
    pub declined: std::collections::HashSet<i64>,
328 338
}
329 339
330 340
impl Room {
…
67 unchanged lines
398 408
    .fetch_all(&mut *tx)
399 409
    .await?;
400 410
    for j in queued {
401 -
        if !runtimes.iter().any(|r| r == &j.runtime) || !room.fits(&j.sandbox) {
411 +
        if !runtimes.iter().any(|r| r == &j.runtime) || !room.fits(&j.sandbox) || room.declined.contains(&j.id) {
402 412
            continue;
403 413
        }
404 414
        // Every label the job and its sandbox ask for (`runs-on`).
…
38 unchanged lines
443 453
    }
444 454
    tx.commit().await?;
445 455
    Ok(None)
456 +
}
457 +
458 +
/// How long a decline keeps a job from its runner when the runner's
459 +
/// profile stays the same: a reason such as memory in use passes.
460 +
pub const DECLINE_HOLDS_MS: i64 = 10 * 60_000;
461 +
462 +
/// The jobs runner `runner_id` declined with this profile, recently.
463 +
pub async fn declined_by(pool: &Pool, runner_id: i64, profile: &str) -> sqlx::Result<std::collections::HashSet<i64>> {
464 +
    let rows: Vec<(i64,)> = sqlx::query_as("SELECT job_id FROM flow_declines WHERE runner_id = ? AND profile = ? AND ms > ?")
465 +
        .bind(runner_id)
466 +
        .bind(profile)
467 +
        .bind(now_ms() - DECLINE_HOLDS_MS)
468 +
        .fetch_all(pool)
469 +
        .await?;
470 +
    Ok(rows.into_iter().map(|(j,)| j).collect())
471 +
}
472 +
473 +
/// A decline: one runner's reason for handing a job back.
474 +
#[derive(Clone, Debug, sqlx::FromRow)]
475 +
pub struct DeclineRow {
476 +
    pub job_id: i64,
477 +
    pub runner_id: i64,
478 +
    pub runner: String,
479 +
    pub reason: String,
480 +
    pub ms: i64,
481 +
}
482 +
483 +
/// Runner `runner_id` hands back job `job_id`, running on it: the decline
484 +
/// is kept (one per job and runner, the latest), and the job is queued
485 +
/// again. Returns every decline the job has had, or `None` when the job
486 +
/// was not running on that runner.
487 +
pub async fn decline(
488 +
    pool: &Pool,
489 +
    job_id: i64,
490 +
    runner_id: i64,
491 +
    runner: &str,
492 +
    profile: &str,
493 +
    reason: &str,
494 +
) -> sqlx::Result<Option<Vec<DeclineRow>>> {
495 +
    let mut tx = super::begin_write(pool).await?;
496 +
    let back = sqlx::query(
497 +
        "UPDATE flow_jobs SET status = 'queued', runner = '', token_hash = '', started_ms = NULL, runner_id = NULL \
498 +
         WHERE id = ? AND status = 'running' AND runner_id = ?",
499 +
    )
500 +
    .bind(job_id)
501 +
    .bind(runner_id)
502 +
    .execute(&mut *tx)
503 +
    .await?;
504 +
    if back.rows_affected() != 1 {
505 +
        tx.rollback().await?;
506 +
        return Ok(None);
507 +
    }
508 +
    sqlx::query(
509 +
        "INSERT INTO flow_declines (job_id, runner_id, runner, profile, reason, ms) VALUES (?, ?, ?, ?, ?, ?) \
510 +
         ON CONFLICT (job_id, runner_id) DO UPDATE SET runner = excluded.runner, profile = excluded.profile, reason = excluded.reason, ms = excluded.ms",
511 +
    )
512 +
    .bind(job_id)
513 +
    .bind(runner_id)
514 +
    .bind(runner)
515 +
    .bind(profile)
516 +
    .bind(reason)
517 +
    .bind(now_ms())
518 +
    .execute(&mut *tx)
519 +
    .await?;
520 +
    let all: Vec<DeclineRow> =
521 +
        sqlx::query_as("SELECT job_id, runner_id, runner, reason, ms FROM flow_declines WHERE job_id = ? ORDER BY ms")
522 +
            .bind(job_id)
523 +
            .fetch_all(&mut *tx)
524 +
            .await?;
525 +
    tx.commit().await?;
526 +
    Ok(Some(all))
527 +
}
528 +
529 +
/// The declines of job `job_id`, oldest first.
530 +
pub async fn declines_of_job(pool: &Pool, job_id: i64) -> sqlx::Result<Vec<DeclineRow>> {
531 +
    sqlx::query_as("SELECT job_id, runner_id, runner, reason, ms FROM flow_declines WHERE job_id = ? ORDER BY ms")
532 +
        .bind(job_id)
533 +
        .fetch_all(pool)
534 +
        .await
535 +
}
536 +
537 +
/// A runner's latest declines, newest first, with where the job is.
538 +
#[derive(Clone, Debug, sqlx::FromRow)]
539 +
pub struct RunnerDecline {
540 +
    pub job_id: i64,
541 +
    pub job: String,
542 +
    pub run_number: i64,
543 +
    pub owner: String,
544 +
    pub name: String,
545 +
    pub reason: String,
546 +
    pub ms: i64,
547 +
}
548 +
549 +
pub async fn declines_of_runner(pool: &Pool, runner_id: i64, limit: i64) -> sqlx::Result<Vec<RunnerDecline>> {
550 +
    sqlx::query_as(
551 +
        "SELECT d.job_id, j.name AS job, r.number AS run_number, rep.owner, rep.name, d.reason, d.ms \
552 +
         FROM flow_declines d JOIN flow_jobs j ON j.id = d.job_id JOIN flow_runs r ON r.id = j.run_id \
553 +
         JOIN repositories rep ON rep.id = r.repo_id WHERE d.runner_id = ? ORDER BY d.ms DESC LIMIT ?",
554 +
    )
555 +
    .bind(runner_id)
556 +
    .bind(limit)
557 +
    .fetch_all(pool)
558 +
    .await
559 +
}
560 +
561 +
/// Queued jobs whose dependencies have passed, in their live runs, with
562 +
/// their repositories (owner, id): the ones a runner could take now.
563 +
pub async fn ready_queued(pool: &Pool) -> sqlx::Result<Vec<(Job, String, i64)>> {
564 +
    let queued: Vec<Job> = sqlx::query_as(
565 +
        "SELECT j.* FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id \
566 +
         WHERE j.status = 'queued' AND r.status IN ('queued', 'running') ORDER BY j.run_id, j.depth, j.id LIMIT 500",
567 +
    )
568 +
    .fetch_all(pool)
569 +
    .await?;
570 +
    let mut out = Vec::new();
571 +
    for j in queued {
572 +
        let mut ready = true;
573 +
        for d in j.depends_on() {
574 +
            let st: Option<String> = sqlx::query_scalar("SELECT status FROM flow_jobs WHERE run_id = ? AND name = ?")
575 +
                .bind(j.run_id)
576 +
                .bind(&d)
577 +
                .fetch_optional(pool)
578 +
                .await?;
579 +
            if st.as_deref() != Some("passed") {
580 +
                ready = false;
581 +
                break;
582 +
            }
583 +
        }
584 +
        if !ready {
585 +
            continue;
586 +
        }
587 +
        let repo: Option<(String, i64)> =
588 +
            sqlx::query_as("SELECT rep.owner, rep.id FROM flow_runs r JOIN repositories rep ON rep.id = r.repo_id WHERE r.id = ?")
589 +
                .bind(j.run_id)
590 +
                .fetch_optional(pool)
591 +
                .await?;
592 +
        if let Some((owner, id)) = repo {
593 +
            out.push((j, owner, id));
594 +
        }
595 +
    }
596 +
    Ok(out)
597 +
}
598 +
599 +
pub async fn set_waiting(pool: &Pool, job_id: i64, waiting: &str) -> sqlx::Result<()> {
600 +
    sqlx::query("UPDATE flow_jobs SET waiting = ? WHERE id = ? AND waiting <> ?")
601 +
        .bind(waiting)
602 +
        .bind(job_id)
603 +
        .bind(waiting)
604 +
        .execute(pool)
605 +
        .await?;
606 +
    Ok(())
446 607
}
447 608
448 609
/// Append records; returns how many were kept (the rest exceed the cap).
…
262 unchanged lines
modified crates/hub/src/db/runners.rs +37 −1
…
3 unchanged lines
4 4
use super::{now_ms, Pool};
5 5
use sqlx::FromRow;
6 6
7 -
#[derive(Clone, Debug, FromRow)]
7 +
#[derive(Clone, Debug, Default, FromRow)]
8 8
pub struct Runner {
9 9
    pub id: i64,
10 10
    pub name: String,
…
20 unchanged lines
31 31
    pub admin_labels: String,
32 32
    /// A time limit for its jobs in minutes, below the site's, when set.
33 33
    pub timeout_minutes: Option<i64>,
34 +
    /// The tiers it offered at its last claim, comma-separated.
35 +
    pub tiers: String,
36 +
    /// The images it held then (a JSON array); empty when it did not say.
37 +
    pub images: String,
38 +
    /// BLAKE3 of what it offers; its declines hold until this changes.
39 +
    pub profile: String,
34 40
}
35 41
36 42
/// Which repositories' jobs a runner may take (oxygenhub#41).
…
117 unchanged lines
154 160
    if let Some(n) = name {
155 161
        sqlx::query("UPDATE flow_runners SET name = ? WHERE id = ? AND source = 'config'").bind(n).bind(id).execute(pool).await?;
156 162
    }
163 +
    Ok(())
164 +
}
165 +
166 +
/// What a runner offers at a claim (OxygenFlow spec §11.1): its tiers and
167 +
/// images, and the profile hash over those, its architecture, labels and
168 +
/// capacity.
169 +
pub async fn set_offer(pool: &Pool, id: i64, tiers: &[String], images: Option<&[String]>, profile: &str) -> sqlx::Result<()> {
170 +
    let images = images.map(|i| serde_json::to_string(i).unwrap_or_default()).unwrap_or_default();
171 +
    sqlx::query("UPDATE flow_runners SET tiers = ?, images = ?, profile = ? WHERE id = ?")
172 +
        .bind(tiers.join(","))
173 +
        .bind(images)
174 +
        .bind(profile)
175 +
        .bind(id)
176 +
        .execute(pool)
177 +
        .await?;
157 178
    Ok(())
179 +
}
180 +
181 +
impl Runner {
182 +
    pub fn tier_list(&self) -> Vec<&str> {
183 +
        self.tiers.split(',').filter(|t| !t.is_empty()).collect()
184 +
    }
185 +
186 +
    /// The images it holds, `None` when it does not say.
187 +
    pub fn image_list(&self) -> Option<Vec<String>> {
188 +
        if self.images.is_empty() {
189 +
            None
190 +
        } else {
191 +
            serde_json::from_str(&self.images).ok()
192 +
        }
193 +
    }
158 194
}
159 195
160 196
pub async fn set_state(pool: &Pool, id: i64, state: &str) -> sqlx::Result<()> {
…
181 unchanged lines
modified crates/hub/src/flow.rs +235 −0
…
466 unchanged lines
467 467
        fdb::record_image_archs(&app.db, archs).await?;
468 468
    }
469 469
    let arch = req.info.as_ref().map(|i| i.arch.clone()).filter(|a| !a.is_empty()).unwrap_or_else(|| me.arch.clone());
470 +
    // What it offers, kept for the placement pass; a job it declined is
471 +
    // not offered to it again while this stays the same (spec §11.5).
472 +
    let profile = profile_of(&req, &arch);
473 +
    db::runners::set_offer(&app.db, me.id, &req.runtimes, req.images.as_deref(), &profile).await?;
470 474
    let room = fdb::Room {
475 +
        declined: fdb::declined_by(&app.db, me.id, &profile).await?,
471 476
        memory_mib: req.free_memory_mib,
472 477
        cpus: req.free_cpus,
473 478
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
…
109 unchanged lines
583 588
    Ok(Json(claim).into_response())
584 589
}
585 590
591 +
/// BLAKE3 over what a claiming runner offers: tiers, images, architecture,
592 +
/// labels and capacity, each list sorted.
593 +
fn profile_of(req: &ClaimRequest, arch: &str) -> String {
594 +
    let sorted = |v: &[String]| {
595 +
        let mut v = v.to_vec();
596 +
        v.sort();
597 +
        v
598 +
    };
599 +
    let info = req.info.as_ref();
600 +
    let v = serde_json::json!({
601 +
        "arch": arch,
602 +
        "tiers": sorted(&req.runtimes),
603 +
        "images": req.images.as_deref().map(sorted),
604 +
        "labels": info.map(|i| sorted(&i.labels)),
605 +
        "memory_mib": info.and_then(|i| i.memory_mib),
606 +
        "cpus": info.and_then(|i| i.cpus),
607 +
    });
608 +
    blake3::hash(v.to_string().as_bytes()).to_hex().to_string()
609 +
}
610 +
611 +
/// A job handed back by this many different runners is unplaceable.
612 +
pub const UNPLACEABLE_AFTER: usize = 3;
613 +
614 +
/// `POST /api/v1/flow/jobs/{id}/decline` (OxygenFlow spec §11.5): the
615 +
/// runner will not run a job it was handed. The job is queued again and
616 +
/// not offered to that runner while its profile stays the same (and for
617 +
/// [`fdb::DECLINE_HOLDS_MS`] at most); once [`UNPLACEABLE_AFTER`]
618 +
/// different runners have declined it, it ends as unplaceable with their
619 +
/// reasons. A decline is never a failed check.
620 +
pub async fn decline(
621 +
    State(app): State<Shared>,
622 +
    headers: HeaderMap,
623 +
    Path(id): Path<i64>,
624 +
    Json(d): Json<flow_core::api::Decline>,
625 +
) -> Result<StatusCode, ApiError> {
626 +
    let me = runner_auth(&app, &headers).await?;
627 +
    db::runners::seen(&app.db, me.id, None, None).await?;
628 +
    let reason: String = d.reason.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect();
629 +
    let reason = match reason.trim() {
630 +
        "" => "no reason given".to_string(),
631 +
        r => r.to_string(),
632 +
    };
633 +
    let Some(all) = fdb::decline(&app.db, id, me.id, &me.name, &me.profile, &reason).await? else {
634 +
        return Err(ApiError(StatusCode::CONFLICT, "that job is not running on this runner".into()));
635 +
    };
636 +
    tracing::info!("flow: runner {} declined job {id}: {reason}", me.name);
637 +
    let job = fdb::job(&app.db, id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?;
638 +
    let runners: std::collections::BTreeSet<i64> = all.iter().map(|d| d.runner_id).collect();
639 +
    if runners.len() >= UNPLACEABLE_AFTER {
640 +
        let error = format!(
641 +
            "unplaceable: declined by {} runners: {}",
642 +
            runners.len(),
643 +
            all.iter().map(|d| format!("{}: {}", d.runner, d.reason)).collect::<Vec<_>>().join("; ")
644 +
        );
645 +
        let changed =
646 +
            fdb::finish(&app.db, id, fdb::Outcome { status: "error", exit_code: None, error: &error, receipt: "", receipt_hash: "" })
647 +
                .await?;
648 +
        report_changed(&app, job.run_id, changed).await?;
649 +
    } else if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
650 +
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
651 +
            let summary = format!("declined by {}: {reason}; waiting for another runner", me.name);
652 +
            report_check(&app, &row, &run.state, run.number, &job.name, "queued", &summary).await;
653 +
            fdb::set_waiting(&app.db, id, &summary).await?;
654 +
        }
655 +
    }
656 +
    Ok(StatusCode::NO_CONTENT)
657 +
}
658 +
659 +
/// Why no runner can take `job` of a repository of `owner` (id `repo_id`)
660 +
/// now (spec §11.4), from what each runner offered at its last claim:
661 +
/// empty when one could (it waits its turn or a free slot), else what
662 +
/// each one lacks. `declines` are the job's.
663 +
pub fn why_waiting(
664 +
    job: &fdb::Job,
665 +
    owner: &str,
666 +
    repo_id: i64,
667 +
    runners: &[db::runners::Runner],
668 +
    image_archs: &std::collections::HashMap<String, String>,
669 +
    declines: &[fdb::DeclineRow],
670 +
    now: i64,
671 +
) -> String {
672 +
    let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default();
673 +
    let rootfs = sandbox["rootfs"].as_str().unwrap_or("");
674 +
    let wanted_arch = job.arch().or_else(|| image_archs.get(rootfs).cloned());
675 +
    let need = |k: &str| sandbox[k].as_u64().unwrap_or(0);
676 +
    let needs = job.needs();
677 +
    let lacks = |r: &db::runners::Runner| -> Option<String> {
678 +
        if r.state == "draining" {
679 +
            return Some("is draining".into());
680 +
        }
681 +
        let scope = r.scope();
682 +
        if (!scope.owner.is_empty() && scope.owner != owner) || scope.repo_id.is_some_and(|id| id != repo_id) {
683 +
            return Some("serves other repositories".into());
684 +
        }
685 +
        let tiers = r.tier_list();
686 +
        if !tiers.is_empty() && !tiers.contains(&job.runtime.as_str()) {
687 +
            return Some(format!("has no {} tier (it offers {})", job.runtime, tiers.join(", ")));
688 +
        }
689 +
        if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) {
690 +
            return Some(format!("is {}; the job needs {w}", r.arch));
691 +
        }
692 +
        if r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
693 +
            return Some(format!("does not hold image {}", &rootfs[..12.min(rootfs.len())]));
694 +
        }
695 +
        let labels = r.label_list();
696 +
        if let Some(l) = needs.iter().find(|l| !labels.contains(&l.as_str())) {
697 +
            return Some(format!("lacks label {l}"));
698 +
        }
699 +
        if let Some(m) = r.memory_mib.filter(|m| (*m as u64) < need("memory_mib")) {
700 +
            return Some(format!("has {m} MiB; the job needs {} MiB", need("memory_mib")));
701 +
        }
702 +
        if let Some(c) = r.cpus.filter(|c| (*c as u64) < need("cpus")) {
703 +
            return Some(format!("has {c} CPUs; the job needs {}", need("cpus")));
704 +
        }
705 +
        if let Some(d) = declines.iter().find(|d| d.runner_id == r.id && now - d.ms < fdb::DECLINE_HOLDS_MS) {
706 +
            return Some(format!("declined it: {}", d.reason));
707 +
        }
708 +
        None
709 +
    };
710 +
    let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect();
711 +
    if online.is_empty() {
712 +
        return "no runner is online".into();
713 +
    }
714 +
    let mut why = Vec::new();
715 +
    for r in &online {
716 +
        match lacks(r) {
717 +
            None => return String::new(),
718 +
            Some(w) => why.push(format!("{} {w}", r.name)),
719 +
        }
720 +
    }
721 +
    let more = why.len().saturating_sub(4);
722 +
    why.truncate(4);
723 +
    let mut text = format!("no runner can take it: {}", why.join("; "));
724 +
    if more > 0 {
725 +
        text.push_str(&format!("; and {more} more"));
726 +
    }
727 +
    text
728 +
}
729 +
730 +
/// One placement pass: say why each ready queued job waits.
731 +
pub async fn explain_waits(app: &Shared) -> sqlx::Result<()> {
732 +
    let ready = fdb::ready_queued(&app.db).await?;
733 +
    if ready.is_empty() {
734 +
        return Ok(());
735 +
    }
736 +
    let runners = db::runners::list(&app.db).await?;
737 +
    let archs = fdb::image_archs(&app.db).await?;
738 +
    let now = db::now_ms();
739 +
    for (job, owner, repo_id) in ready {
740 +
        let declines = fdb::declines_of_job(&app.db, job.id).await?;
741 +
        let why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now);
742 +
        fdb::set_waiting(&app.db, job.id, &why).await?;
743 +
    }
744 +
    Ok(())
745 +
}
746 +
747 +
/// Run a placement pass every 15 seconds.
748 +
pub async fn placement(app: Shared) {
749 +
    loop {
750 +
        tokio::time::sleep(std::time::Duration::from_secs(15)).await;
751 +
        if let Err(e) = explain_waits(&app).await {
752 +
            tracing::warn!("flow: placement pass: {e}");
753 +
        }
754 +
    }
755 +
}
756 +
586 757
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
587 758
async fn pins_in(
588 759
    app: &Shared,
…
566 unchanged lines
1155 1326
        assert_eq!(
1156 1327
            [ordinal(1), ordinal(2), ordinal(11), ordinal(22), ordinal(113)],
1157 1328
            ["1st in line", "2nd in line", "11th in line", "22nd in line", "113th in line"]
1329 +
        );
1330 +
    }
1331 +
}
1332 +
1333 +
#[cfg(test)]
1334 +
mod placement_tests {
1335 +
    use super::*;
1336 +
1337 +
    #[test]
1338 +
    fn a_waiting_job_says_what_each_runner_lacks() {
1339 +
        use crate::db::runners::Runner;
1340 +
        let rootfs = "ab".repeat(32);
1341 +
        let job = fdb::Job {
1342 +
            id: 7,
1343 +
            runtime: "syscall".into(),
1344 +
            sandbox: serde_json::json!({ "rootfs": rootfs, "memory_mib": 4096, "cpus": 2 }).to_string(),
1345 +
            spec: serde_json::json!({ "name": "build" }).to_string(),
1346 +
            ..Default::default()
1347 +
        };
1348 +
        let now = db::now_ms();
1349 +
        let runner = |id: i64, name: &str| Runner {
1350 +
            id,
1351 +
            name: name.into(),
1352 +
            state: "active".into(),
1353 +
            last_seen_ms: Some(now),
1354 +
            arch: "x86_64".into(),
1355 +
            tiers: "microvm,syscall".into(),
1356 +
            images: serde_json::to_string(&[&rootfs]).unwrap(),
1357 +
            memory_mib: Some(16384),
1358 +
            cpus: Some(8),
1359 +
            ..Default::default()
1360 +
        };
1361 +
        let archs = std::collections::HashMap::new();
1362 +
        let why = |runners: &[Runner], declines: &[fdb::DeclineRow]| why_waiting(&job, "andy", 1, runners, &archs, declines, now);
1363 +
        assert_eq!(why(&[], &[]), "no runner is online");
1364 +
        assert_eq!(why(&[runner(1, "earth-1")], &[]), "", "one can take it: it only waits its turn");
1365 +
        let microvm_only = Runner { tiers: "microvm".into(), ..runner(1, "earth-1") };
1366 +
        let riscv = Runner { arch: "riscv64".into(), ..runner(2, "narvi") };
1367 +
        let small = Runner { memory_mib: Some(2048), ..runner(3, "pi") };
1368 +
        let other = Runner { scope_owner: "bob".into(), ..runner(4, "bobs") };
1369 +
        let offline = Runner { last_seen_ms: Some(now - 3_600_000), ..runner(5, "gone") };
1370 +
        assert_eq!(
1371 +
            why(&[microvm_only, small, other, offline], &[]),
1372 +
            "no runner can take it: earth-1 has no syscall tier (it offers microvm); pi has 2048 MiB; the job needs 4096 MiB; bobs serves other repositories"
1373 +
        );
1374 +
        let archs_known: std::collections::HashMap<String, String> = [(rootfs.clone(), "x86_64".to_string())].into();
1375 +
        assert_eq!(
1376 +
            why_waiting(&job, "andy", 1, &[riscv], &archs_known, &[], now),
1377 +
            "no runner can take it: narvi is riscv64; the job needs x86_64"
1378 +
        );
1379 +
        let without = Runner { images: "[]".into(), ..runner(6, "new") };
1380 +
        assert_eq!(why(&[without], &[]), "no runner can take it: new does not hold image abababababab");
1381 +
        let d = fdb::DeclineRow {
1382 +
            job_id: 7,
1383 +
            runner_id: 1,
1384 +
            runner: "earth-1".into(),
1385 +
            reason: "only 512 MiB of memory is free here".into(),
1386 +
            ms: now,
1387 +
        };
1388 +
        assert_eq!(
1389 +
            why(&[runner(1, "earth-1")], std::slice::from_ref(&d)),
1390 +
            "no runner can take it: earth-1 declined it: only 512 MiB of memory is free here"
1158 1391
        );
1392 +
        let old = fdb::DeclineRow { ms: now - fdb::DECLINE_HOLDS_MS - 1, ..d };
1393 +
        assert_eq!(why(&[runner(1, "earth-1")], &[old]), "", "an old decline no longer holds");
1159 1394
    }
1160 1395
}
modified crates/hub/src/lib.rs +1 −0
…
113 unchanged lines
114 114
    tokio::spawn(hooks::worker(app.clone()));
115 115
    tokio::spawn(flow::reaper(app.clone()));
116 116
    tokio::spawn(flow::scheduler(app.clone()));
117 +
    tokio::spawn(flow::placement(app.clone()));
117 118
    tokio::spawn(maintenance::nightly(app.clone()));
118 119
    Ok(app)
119 120
}
…
42 unchanged lines
modified crates/hub/src/web/runners.rs +3 −1
…
280 unchanged lines
281 281
    r: runners::Runner,
282 282
    running: i64,
283 283
    history: Vec<runners::RunnerJob>,
284 +
    declines: Vec<crate::db::flow::RunnerDecline>,
284 285
    older: Option<i64>,
285 286
    jobs: i64,
286 287
    passed: i64,
…
30 unchanged lines
317 318
    history.truncate(PER_PAGE as usize);
318 319
    let (jobs, passed, busy_ms) = runners::totals(&app.db, id).await.map_err(e)?;
319 320
    let running = runners::running(&app.db).await.map_err(e)?.into_iter().find(|(rid, _)| *rid == id).map(|(_, n)| n).unwrap_or(0);
320 -
    Ok(Page(RunnerPage { shell, r, running, history, older, jobs, passed, busy_ms }))
321 +
    let declines = crate::db::flow::declines_of_runner(&app.db, id, 20).await.map_err(e)?;
322 +
    Ok(Page(RunnerPage { shell, r, running, history, declines, older, jobs, passed, busy_ms }))
321 323
}
322 324
323 325
pub fn duration(j: &runners::RunnerJob) -> String {
…
83 unchanged lines
modified crates/hub/tests/flow.rs +62 −1
…
41 unchanged lines
42 42
    let dir = tempfile::tempdir().unwrap();
43 43
    let mut config = Config::load(dir.path()).unwrap();
44 44
    config.auth.push_tokens = vec!["site".into()];
45 -
    config.flow.runner_tokens = vec!["runner-secret".into()];
45 +
    config.flow.runner_tokens = vec!["runner-secret".into(), "runner-two".into(), "runner-three".into()];
46 46
    config.flow.repos = vec!["andy/*".into()];
47 47
    config.registration.mode = "open".into();
48 48
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
…
2016 unchanged lines
2065 2065
    let (_, queue) = call(app, "GET", "/admin/runners", None, None).await;
2066 2066
    let _ = queue;
2067 2067
}
2068 +
2069 +
/// OxygenFlow spec §11.5: a runner hands back a job it cannot run. The
2070 +
/// job is queued again and not offered to that runner again, the decline
2071 +
/// shows on the run's page and is kept for the runner's, and once three
2072 +
/// runners have declined it the job ends as unplaceable with their reasons.
2073 +
#[tokio::test(flavor = "multi_thread")]
2074 +
async fn a_declined_job_goes_to_another_runner_or_ends_unplaceable() {
2075 +
    let h = hub().await;
2076 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
2077 +
    create(&h.app, "andy", "declines").await;
2078 +
    let url = format!("{}/andy/declines", h.base);
2079 +
    let work = tempfile::tempdir().unwrap();
2080 +
    let c = client(&url, work.path());
2081 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline(""));
2082 +
    write(work.path(), "test.sh", "echo ok\n");
2083 +
    c.commit("Add a pipeline", &[]).unwrap();
2084 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2085 +
    let run = runs_of(&h.app, "andy", "declines").await[0].clone();
2086 +
    let claim_as = |token: &'static str, name: &'static str| {
2087 +
        let app = h.app.clone();
2088 +
        async move {
2089 +
            let body = serde_json::json!({ "runner": name, "runtimes": ["microvm"] }).to_string();
2090 +
            let (st, body) = call(&app, "POST", "/api/v1/flow/claim", Some(token), Some(body)).await;
2091 +
            (st == StatusCode::OK).then(|| serde_json::from_str::<serde_json::Value>(&body).unwrap()["job_id"].as_i64().unwrap())
2092 +
        }
2093 +
    };
2094 +
    let decline = |token: &'static str, id: i64, reason: &'static str| {
2095 +
        let app = h.app.clone();
2096 +
        async move {
2097 +
            let body = serde_json::json!({ "reason": reason }).to_string();
2098 +
            call(&app, "POST", &format!("/api/v1/flow/jobs/{id}/decline"), Some(token), Some(body)).await.0
2099 +
        }
2100 +
    };
2101 +
    let job = claim_as("runner-secret", "earth-1").await.expect("a job");
2102 +
    // The other runners are known to the hub before they decline anything.
2103 +
    assert_eq!(decline("runner-two", job, "not mine").await, StatusCode::CONFLICT, "only the runner it runs on may decline it");
2104 +
    assert_eq!(decline("runner-secret", job, "the MicroVM tier needs /dev/kvm").await, StatusCode::NO_CONTENT);
2105 +
    let j = db::flow::job(&h.app.db, job).await.unwrap().unwrap();
2106 +
    assert_eq!((j.status.as_str(), j.runner.as_str()), ("queued", ""));
2107 +
    assert_eq!(j.waiting, "declined by earth-1: the MicroVM tier needs /dev/kvm; waiting for another runner");
2108 +
    // earth-1 is offered the other ready job, not this one, while it stays the same.
2109 +
    let next = claim_as("runner-secret", "earth-1").await.expect("the other ready job");
2110 +
    assert_ne!(next, job);
2111 +
    let page = call(&h.app, "GET", &format!("/andy/declines/flow/{}", run.number), None, None).await.1;
2112 +
    assert!(page.contains("Waiting: declined by earth-1: the MicroVM tier needs /dev/kvm"), "{page}");
2113 +
    assert_eq!(claim_as("runner-two", "plex-1").await, Some(job), "another runner takes it");
2114 +
    assert_eq!(decline("runner-two", job, "rootfs 36fc4e323804 is not on this runner").await, StatusCode::NO_CONTENT);
2115 +
    assert_eq!(claim_as("runner-three", "narvi").await, Some(job));
2116 +
    assert_eq!(decline("runner-three", job, "rootfs 36fc4e323804 is for x86_64; this runner is riscv64").await, StatusCode::NO_CONTENT);
2117 +
    let j = db::flow::job(&h.app.db, job).await.unwrap().unwrap();
2118 +
    assert_eq!(j.status, "error");
2119 +
    assert_eq!(
2120 +
        j.error,
2121 +
        "unplaceable: declined by 3 runners: earth-1: the MicroVM tier needs /dev/kvm; plex-1: rootfs 36fc4e323804 is not on this runner; narvi: rootfs 36fc4e323804 is for x86_64; this runner is riscv64"
2122 +
    );
2123 +
    let r = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "plex-1").unwrap();
2124 +
    let declined = db::flow::declines_of_runner(&h.app.db, r.id, 10).await.unwrap();
2125 +
    assert_eq!(declined.len(), 1);
2126 +
    assert_eq!((declined[0].job_id, declined[0].reason.as_str()), (job, "rootfs 36fc4e323804 is not on this runner"));
2127 +
    assert_eq!(r.tier_list(), vec!["microvm"]);
2128 +
}
replaced deps/oxygenflow +0 −0

No line changes.

modified templates/admin_runner.html +15 −0
…
8 unchanged lines
9 9
    <dt>Machine</dt><dd>{% if r.arch.is_empty() %}<span class="mute">not reported yet</span>{% else %}{{ r.arch }} · {{ r.os }} · flow {{ r.version }}{% endif %}</dd>
10 10
    <dt>Labels</dt><dd>{% for l in r.label_list() %}<span class="lbl">{{ l }}</span> {% endfor %}{% if r.label_list().is_empty() %}<span class="mute">none</span>{% endif %}{% if !r.admin_label_list().is_empty() %} <span class="mute small">({{ r.admin_label_list().len() }} set here)</span>{% endif %}</dd>
11 11
    <dt>Time limit</dt><dd>{% if let Some(m) = r.timeout_minutes %}{{ m }} min for its jobs, below the site's{% else %}<span class="mute">the site's</span>{% endif %}</dd>
12 +
    <dt>Tiers</dt><dd>{% if r.tier_list().is_empty() %}<span class="mute">not reported yet</span>{% else %}{{ r.tier_list().join(", ") }}{% endif %}{% if let Some(i) = r.image_list() %} · {{ i.len()|plural("image", "images") }} held{% endif %}</dd>
12 13
    <dt>Capacity</dt><dd>{{ r.max_jobs|plural("job", "jobs") }} at once{% if let Some(m) = r.memory_mib %}, {{ m }} MiB{% endif %}{% if let Some(c) = r.cpus %}, {{ c }} vCPU{% endif %}</dd>
13 14
    <dt>Running now</dt><dd>{{ running|plural("job", "jobs") }}</dd>
14 15
    <dt>Ran</dt><dd>{{ jobs|plural("job", "jobs") }}, {{ passed }} passed{% if jobs > 0 %} ({{ passed * 100 / jobs }}%){% endif %}, busy {{ self.busy() }}</dd>
…
12 unchanged lines
27 28
    <button type="submit">SAVE</button>
28 29
  </form>
29 30
</section>
31 +
{% if !declines.is_empty() %}<section class="panel">
32 +
  <h2>Declined</h2>
33 +
  <p class="small mute">Jobs this runner handed back before running them, and why (OxygenFlow spec §11.5).</p>
34 +
  <div class="table"><table>
35 +
  <tr><th>Repository</th><th>Run</th><th>Job</th><th>Reason</th><th>When</th></tr>
36 +
  {% for d in declines %}<tr>
37 +
    <td><a href="/{{ d.owner }}/{{ d.name }}">{{ d.owner }}/{{ d.name }}</a></td>
38 +
    <td><a href="/{{ d.owner }}/{{ d.name }}/flow/{{ d.run_number }}">#{{ d.run_number }}</a></td>
39 +
    <td>{{ d.job }}</td>
40 +
    <td>{{ d.reason }}</td>
41 +
    <td class="small"><time title="{{ d.ms|when_i }}">{{ d.ms|ago_i }}</time></td>
42 +
  </tr>{% endfor %}
43 +
  </table></div>
44 +
</section>{% endif %}
30 45
<section class="panel">
31 46
  <h2>Jobs</h2>
32 47
  {% if history.is_empty() %}<p class="mute">No jobs yet.</p>{% else %}
…
16 unchanged lines
modified templates/flow_run.html +1 −1
…
36 unchanged lines
37 37
  {% if !l.artifacts.is_empty() %}<ul class="flow-artifacts small">{% for a in l.artifacts %}<li><a href="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}">{{ a.name }}</a> <span class="mute">{{ a.path }} · {{ a.size|bytes }} · blob <code title="{{ a.blob }}">{{ a.blob|short }}</code></span>{% if !releases.is_empty() %} <form class="tiny inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}/attach"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="release" aria-label="Release to attach {{ a.name }} to">{% for r in releases %}<option value="{{ r.id }}">{{ r.tag }}</option>{% endfor %}</select> <button>Attach to release</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %}
38 38
  <pre class="flow-log" data-log data-seq="{{ l.last_seq }}">{% for line in l.lines %}<span class="ln s-{{ line.stream }}"><span class="t">{{ line.t / 1000 }}.{{ "{:01}"|format(line.t % 1000 / 100) }}s</span>{{ line.text }}</span>
39 39
{% endfor %}</pre>
40 -
  {% if l.lines.is_empty() && l.j.status == "queued" %}<p class="small mute pad">Waiting for a runner{% if !l.j.depends_on().is_empty() %} and for {{ l.j.depends_on().join(", ") }}{% endif %}.</p>{% endif %}
40 +
  {% if l.j.status == "queued" && !l.j.waiting.is_empty() %}<p class="small warn pad" data-waiting>Waiting: {{ l.j.waiting }}.</p>{% else if l.lines.is_empty() && l.j.status == "queued" %}<p class="small mute pad">Waiting for a runner{% if !l.j.depends_on().is_empty() %} and for {{ l.j.depends_on().join(", ") }}{% endif %}.</p>{% endif %}
41 41
  {% if l.lines.is_empty() && l.j.status == "skipped" %}<p class="small mute pad">Skipped: a job it depends on did not pass.</p>{% endif %}
42 42
</details>
43 43
{% endfor %}
…
2 unchanged lines