feat(data): implement JobStore and the Gitea read client
All checks were successful
deploy / deploy (push) Successful in 6m1s
All checks were successful
deploy / deploy (push) Successful in 6m1s
Closes #3 and #4. Both specs had gaps that only appeared on contact, which is worth recording because it is evidence for the plan-quality question in #10: - `enqueue(&[DiscoveredIssue])` cannot know a JobKind. It is derivable from labels, so the store now carries the LabelProtocol and core gained `job_kind_for`. The precedence when an operator applies several mode labels had to be decided: plan beats implement, because planning produces the implementation children and so loses nothing, while the reverse silently discards the decomposition that was also asked for. - `claim_next(worker, allowed_lanes)` had no lane to filter on. Migration 0002 adds one, recorded at enqueue by `routing::lane_for` — the same function `route` now delegates to, so the two cannot drift. Deriving it at claim time instead would mean a forge request per claim, since labels are not stored. - `list_opted_in_issues` returned a bare Vec with nowhere to put the ETag that #4's own step 3 requires, so a caller could not make the next poll conditional. It returns an `IssuePage` now, which also carries `not_modified` — a 304 is not an empty repo, and a poller that conflated them would treat every quiet poll as every issue having disappeared. The claim is one statement: a CTE takes the row lock with SKIP LOCKED and the update writes the claim, so select and update share a transaction without managing one by hand. Returning a job to pending clears the claim, because `pending_holds_no_claim` refuses the half-done version — the database is what makes lease expiry safe rather than the code remembering to. Enum values round-trip through serde rather than a hand-written match, so the schema's check constraints and the Rust types are provably one vocabulary. Also replaced a test from #2 that asserted exactly one migration exists. It failed on the first legitimate migration, which teaches people to edit the assertion rather than think. It now asserts what it was reaching for: versions unique and ascending. Verified against Postgres 18 — 15 database tests covering enqueue idempotency across repeated polls, lane filtering including the agent:oc override, claim metadata, lease expiry and reclaim, terminal jobs refusing transition, renewal requiring you still hold the claim, and re-routing a queued job when its labels change. Plus 12 mock-forge tests: If-None-Match sent and 304 distinguished from empty, ETag surfaced, 429 retried but bounded, 503 retried then succeeding, 404 not retried, pull requests filtered out, and every unimplemented write failing without touching the forge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
This commit is contained in:
107
Cargo.lock
generated
107
Cargo.lock
generated
@@ -82,6 +82,16 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.91"
|
||||
@@ -425,6 +435,24 @@ version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "deadpool"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
|
||||
dependencies = [
|
||||
"deadpool-runtime",
|
||||
"lazy_static",
|
||||
"num_cpus",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool-runtime"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -542,6 +570,12 @@ dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
@@ -705,6 +739,25 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -737,6 +790,12 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
@@ -825,6 +884,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -1229,6 +1289,16 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_cpus"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -1567,6 +1637,18 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.16"
|
||||
@@ -2313,6 +2395,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2417,6 +2500,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
@@ -3137,6 +3221,29 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wiremock"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
|
||||
dependencies = [
|
||||
"assert-json-diff",
|
||||
"base64",
|
||||
"deadpool",
|
||||
"futures",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.46.0"
|
||||
|
||||
@@ -67,4 +67,5 @@ uuid = { version = "1", features = ["v4", "serde"] }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
figment = { version = "0.10", features = ["toml", "env"] }
|
||||
rand = "0.8"
|
||||
wiremock = "0.6"
|
||||
url = { version = "2", features = ["serde"] }
|
||||
|
||||
@@ -17,14 +17,32 @@ pub struct DiscoveredIssue {
|
||||
pub is_open: bool,
|
||||
}
|
||||
|
||||
/// The result of one conditional poll.
|
||||
///
|
||||
/// Carries the `ETag` as well as the issues, because the caller has to store it
|
||||
/// to make the *next* poll conditional. Returning a bare `Vec` would leave the
|
||||
/// only copy of that value inside the client, which is how a poller that looks
|
||||
/// correct still re-fetches every repo in full on every tick.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct IssuePage {
|
||||
pub issues: Vec<DiscoveredIssue>,
|
||||
/// The `ETag` to send as `If-None-Match` next time, if the forge gave one.
|
||||
pub etag: Option<String>,
|
||||
/// True when the forge answered `304`. `issues` is then empty because
|
||||
/// nothing changed — which is not the same as the repo having no issues,
|
||||
/// and callers must not treat it as such.
|
||||
pub not_modified: bool,
|
||||
}
|
||||
|
||||
/// Read and write access to a forge (Gitea or GitHub).
|
||||
#[async_trait]
|
||||
pub trait ForgeClient: Send + Sync {
|
||||
/// List open issues carrying the opt-in label. Implementations should send a
|
||||
/// conditional request when `repo.last_etag` is set, and return an empty
|
||||
/// vec on `304 Not Modified`.
|
||||
async fn list_opted_in_issues(&self, repo: &TrackedRepo)
|
||||
-> Result<Vec<DiscoveredIssue>, Error>;
|
||||
/// List open issues carrying the opt-in label.
|
||||
///
|
||||
/// Implementations send a conditional request when `repo.last_etag` is set,
|
||||
/// and report `304 Not Modified` as [`IssuePage::not_modified`] rather than
|
||||
/// as an error or an empty success.
|
||||
async fn list_opted_in_issues(&self, repo: &TrackedRepo) -> Result<IssuePage, Error>;
|
||||
|
||||
async fn add_label(&self, issue: &IssueRef, label: &str) -> Result<(), Error>;
|
||||
async fn remove_label(&self, issue: &IssueRef, label: &str) -> Result<(), Error>;
|
||||
|
||||
@@ -24,8 +24,43 @@ pub struct RouteDecision {
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
/// Choose a lane for `job`, given the labels currently on its issue.
|
||||
pub fn route(job: &Job, labels: &[String], protocol: &LabelProtocol) -> RouteDecision {
|
||||
/// What kind of work an issue's labels ask for, if any.
|
||||
///
|
||||
/// Returns `None` when the issue is not opted in, or carries no mode label —
|
||||
/// an issue with `tireless` and nothing else expresses no work to do.
|
||||
///
|
||||
/// **Precedence when several mode labels are present**, which an operator can
|
||||
/// easily do by accident: discover, then plan, then implement. Planning beats
|
||||
/// implementing because a plan *produces* the implementation children, so
|
||||
/// running the plan first is the interpretation that loses nothing; the reverse
|
||||
/// silently discards the decomposition the operator also asked for.
|
||||
pub fn job_kind_for(labels: &[String], protocol: &LabelProtocol) -> Option<JobKind> {
|
||||
let has = |l: &str| labels.iter().any(|x| x == l);
|
||||
if !has(&protocol.opt_in) {
|
||||
return None;
|
||||
}
|
||||
if has(&protocol.mode_discover) {
|
||||
Some(JobKind::Discover)
|
||||
} else if has(&protocol.mode_plan) {
|
||||
Some(JobKind::Plan)
|
||||
} else if has(&protocol.mode_implement) {
|
||||
Some(JobKind::Implement)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose a lane from the facts that determine it, without needing a `Job`.
|
||||
///
|
||||
/// Enqueue has to record a lane before a `Job` exists to pass to [`route`], and
|
||||
/// duplicating the rules there would let the two drift. [`route`] delegates
|
||||
/// here, so there is one implementation and one set of tests.
|
||||
pub fn lane_for(
|
||||
kind: JobKind,
|
||||
has_parent: bool,
|
||||
labels: &[String],
|
||||
protocol: &LabelProtocol,
|
||||
) -> RouteDecision {
|
||||
let has = |l: &str| labels.iter().any(|x| x == l);
|
||||
|
||||
// An explicit operator override beats every inference below.
|
||||
@@ -42,7 +77,7 @@ pub fn route(job: &Job, labels: &[String], protocol: &LabelProtocol) -> RouteDec
|
||||
};
|
||||
}
|
||||
|
||||
match job.kind {
|
||||
match kind {
|
||||
// Proposing work is the highest-judgement task in the system and the
|
||||
// lowest volume: a survey runs on a cadence measured in days, and its
|
||||
// output sets what everything downstream spends its budget on. There is
|
||||
@@ -58,7 +93,7 @@ pub fn route(job: &Job, labels: &[String], protocol: &LabelProtocol) -> RouteDec
|
||||
},
|
||||
// A job descended from a tireless plan has a machine-written spec to work
|
||||
// from, which is what OpenCode against a local model is good at.
|
||||
JobKind::Implement if job.parent_job_id.is_some() => RouteDecision {
|
||||
JobKind::Implement if has_parent => RouteDecision {
|
||||
agent: AgentKind::Opencode,
|
||||
reason: "implements a tireless plan",
|
||||
},
|
||||
@@ -70,6 +105,11 @@ pub fn route(job: &Job, labels: &[String], protocol: &LabelProtocol) -> RouteDec
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose a lane for `job`, given the labels currently on its issue.
|
||||
pub fn route(job: &Job, labels: &[String], protocol: &LabelProtocol) -> RouteDecision {
|
||||
lane_for(job.kind, job.parent_job_id.is_some(), labels, protocol)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -126,6 +166,80 @@ mod tests {
|
||||
assert_eq!(d.agent, AgentKind::ClaudeCode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_issue_without_the_opt_in_label_expresses_no_work() {
|
||||
let p = LabelProtocol::default();
|
||||
// Mode label present, opt-in absent: this is the state a discovered
|
||||
// proposal sits in, and the state an operator leaves behind by removing
|
||||
// one label. It must not enqueue.
|
||||
assert_eq!(
|
||||
job_kind_for(std::slice::from_ref(&p.mode_implement), &p),
|
||||
None
|
||||
);
|
||||
assert_eq!(job_kind_for(std::slice::from_ref(&p.mode_plan), &p), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opted_in_with_no_mode_label_expresses_no_work() {
|
||||
let p = LabelProtocol::default();
|
||||
assert_eq!(job_kind_for(std::slice::from_ref(&p.opt_in), &p), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_mode_label_selects_its_kind() {
|
||||
let p = LabelProtocol::default();
|
||||
for (label, kind) in [
|
||||
(&p.mode_discover, JobKind::Discover),
|
||||
(&p.mode_plan, JobKind::Plan),
|
||||
(&p.mode_implement, JobKind::Implement),
|
||||
] {
|
||||
assert_eq!(
|
||||
job_kind_for(&[p.opt_in.clone(), label.clone()], &p),
|
||||
Some(kind)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planning_wins_over_implementing_when_both_are_asked_for() {
|
||||
// An operator can apply both by accident. Planning first loses nothing —
|
||||
// it produces the implementation children. Implementing first silently
|
||||
// discards the decomposition that was also requested.
|
||||
let p = LabelProtocol::default();
|
||||
assert_eq!(
|
||||
job_kind_for(
|
||||
&[
|
||||
p.opt_in.clone(),
|
||||
p.mode_plan.clone(),
|
||||
p.mode_implement.clone()
|
||||
],
|
||||
&p
|
||||
),
|
||||
Some(JobKind::Plan)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lane_for_and_route_cannot_disagree() {
|
||||
// They are one implementation, and this is what keeps it that way: if
|
||||
// `route` ever stops delegating, enqueue would record one lane and the
|
||||
// runner would pick another, and a job would sit unclaimable forever.
|
||||
let p = LabelProtocol::default();
|
||||
let parent = Uuid::new_v4();
|
||||
for kind in [JobKind::Discover, JobKind::Plan, JobKind::Implement] {
|
||||
for has_parent in [false, true] {
|
||||
for labels in [vec![], vec![p.force_cc.clone()], vec![p.force_oc.clone()]] {
|
||||
let j = job(kind, has_parent.then_some(parent));
|
||||
assert_eq!(
|
||||
route(&j, &labels, &p),
|
||||
lane_for(kind, has_parent, &labels, &p),
|
||||
"{kind:?} parent={has_parent} labels={labels:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_override_beats_inference() {
|
||||
let p = LabelProtocol::default();
|
||||
|
||||
@@ -18,9 +18,11 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
url = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
wiremock = { workspace = true }
|
||||
|
||||
37
crates/tireless-data/migrations/0002_job_lane.sql
Normal file
37
crates/tireless-data/migrations/0002_job_lane.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- The lane a job will run on, recorded when it is enqueued.
|
||||
--
|
||||
-- `JobStore::claim_next` takes the lanes its caller is allowed to start, because
|
||||
-- the governor caps them separately (design.md §5). Without this column that
|
||||
-- filter cannot be expressed in SQL, and a runner would have to claim a job
|
||||
-- before discovering it belongs to a lane that is currently held — taking the
|
||||
-- row out of reach of the runner that could have run it.
|
||||
--
|
||||
-- Denormalised on purpose. The lane is derived from the job kind, whether it has
|
||||
-- a parent, and any `tireless/agent:*` override on the issue — and the labels
|
||||
-- are not stored, so deriving it at claim time would mean a forge request per
|
||||
-- claim. `tireless_core::routing::lane_for` computes it once, at enqueue, and
|
||||
-- the poller refreshes it when labels change. The value is therefore a cache of
|
||||
-- an operator's expressed intent, which is exactly the accuracy the label
|
||||
-- protocol already promises (§2.2).
|
||||
--
|
||||
-- Nullable, with no default: a job enqueued before this migration has no
|
||||
-- recorded lane, and guessing one would be worse than leaving it unclaimable
|
||||
-- until the next poll refreshes it. There are no such jobs today — nothing has
|
||||
-- ever been enqueued — but the reasoning is what matters for the next migration
|
||||
-- that adds a column the claim path depends on.
|
||||
alter table job
|
||||
add column lane text
|
||||
check (lane in ('claude_code', 'opencode'));
|
||||
|
||||
-- Supersedes job_claimable_idx from 0001. The claim now filters on lane as well
|
||||
-- as state, and still orders by created_at alone so `LIMIT 1` can stop at the
|
||||
-- first match rather than sorting the pending set — the property measured in
|
||||
-- #2 at 4 buffers versus 720.
|
||||
--
|
||||
-- Lane leads because it is an equality filter with two values, so it partitions
|
||||
-- the index cleanly; created_at follows to give the ordering for free.
|
||||
drop index job_claimable_idx;
|
||||
|
||||
create index job_claimable_idx
|
||||
on job (lane, created_at)
|
||||
where state = 'pending';
|
||||
@@ -7,21 +7,283 @@
|
||||
//! using the stored `ETag`, a floor on poll interval, and backoff with jitter on
|
||||
//! `429`/`5xx`. Being unattended is not a licence to hammer a forge.
|
||||
|
||||
use reqwest::Client;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::{Client, StatusCode, header};
|
||||
use tireless_core::port::{DiscoveredIssue, ForgeClient, IssuePage};
|
||||
use tireless_entities::{Error, Forge, IssueRef, PullRequestRef, TrackedRepo};
|
||||
use url::Url;
|
||||
|
||||
/// How hard to try before giving up on one repo for this tick.
|
||||
///
|
||||
/// Bounded on purpose: a forge that is down should not be retried until the
|
||||
/// poll interval elapses anyway, and a stuck poller stops every *other* repo
|
||||
/// from being polled too.
|
||||
const MAX_ATTEMPTS: u32 = 3;
|
||||
const BASE_BACKOFF_MS: u64 = 250;
|
||||
|
||||
pub struct GiteaClient {
|
||||
#[allow(dead_code)]
|
||||
http: Client,
|
||||
#[allow(dead_code)]
|
||||
base: Url,
|
||||
/// Token belonging to the dedicated `tireless` bot account — never the
|
||||
/// operator's. Scoped to the repos it collaborates on, and unable to push to
|
||||
/// a protected default branch.
|
||||
#[allow(dead_code)]
|
||||
/// operator's. The account holds no write permission on any repo it works
|
||||
/// on; it pushes to its own fork and opens pull requests across
|
||||
/// repositories (design.md §6.4).
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl GiteaClient {
|
||||
/// Build a client, reading the token from the environment variable named in
|
||||
/// config.
|
||||
///
|
||||
/// The token is *named* in config, never written there. Failing loudly when
|
||||
/// it is absent is deliberate: a poller that starts without credentials
|
||||
/// would report every repo as unreachable, which looks like a forge outage.
|
||||
pub fn from_env(base_url: &str, token_env: &str) -> Result<Self, Error> {
|
||||
let token = std::env::var(token_env).map_err(|_| {
|
||||
Error::Config(format!(
|
||||
"{token_env} is not set; the Gitea token is named in config and \
|
||||
supplied through the unit environment (/etc/tireless/*.env)"
|
||||
))
|
||||
})?;
|
||||
Self::new(base_url, token)
|
||||
}
|
||||
|
||||
pub fn new(base_url: &str, token: impl Into<String>) -> Result<Self, Error> {
|
||||
let base = Url::parse(base_url)
|
||||
.map_err(|e| Error::Config(format!("forge.gitea.base_url {base_url:?}: {e}")))?;
|
||||
Ok(Self {
|
||||
http: Client::builder()
|
||||
.user_agent(concat!("tireless/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.map_err(|e| Error::Forge(e.to_string()))?,
|
||||
base,
|
||||
token: token.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn endpoint(&self, path: &str) -> Result<Url, Error> {
|
||||
self.base
|
||||
.join(&format!("/api/v1/{}", path.trim_start_matches('/')))
|
||||
.map_err(|e| Error::Forge(format!("building {path}: {e}")))
|
||||
}
|
||||
|
||||
/// Backoff for attempt `n`, honouring `Retry-After` when the forge sent one.
|
||||
///
|
||||
/// Jittered so that N repos rate-limited at the same moment do not all
|
||||
/// retry at the same moment and rate-limit each other again.
|
||||
fn backoff(attempt: u32, retry_after: Option<u64>) -> std::time::Duration {
|
||||
if let Some(secs) = retry_after {
|
||||
return std::time::Duration::from_secs(secs.min(60));
|
||||
}
|
||||
let base = BASE_BACKOFF_MS * 2u64.pow(attempt);
|
||||
let jitter = fastrand_ms(base / 2);
|
||||
std::time::Duration::from_millis(base + jitter)
|
||||
}
|
||||
}
|
||||
|
||||
/// Small jitter without pulling in a random dependency at this layer.
|
||||
fn fastrand_ms(max: u64) -> u64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
if max == 0 {
|
||||
return 0;
|
||||
}
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
nanos % max
|
||||
}
|
||||
|
||||
/// Whether a status is worth trying again.
|
||||
fn is_retryable(status: StatusCode) -> bool {
|
||||
status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ForgeClient for GiteaClient {
|
||||
async fn list_opted_in_issues(&self, repo: &TrackedRepo) -> Result<IssuePage, Error> {
|
||||
let mut url = self.endpoint(&format!("repos/{}/{}/issues", repo.owner, repo.repo))?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("state", "open")
|
||||
.append_pair("type", "issues")
|
||||
.append_pair("limit", "50");
|
||||
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
let mut req = self
|
||||
.http
|
||||
.get(url.clone())
|
||||
.header(header::AUTHORIZATION, format!("token {}", self.token));
|
||||
|
||||
// Conditional: the forge answers 304 and sends no body when nothing
|
||||
// has changed, which is most polls of most repos.
|
||||
if let Some(etag) = &repo.last_etag {
|
||||
req = req.header(header::IF_NONE_MATCH, etag);
|
||||
}
|
||||
|
||||
let response = match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) if attempt + 1 < MAX_ATTEMPTS => {
|
||||
tokio::time::sleep(Self::backoff(attempt, None)).await;
|
||||
attempt += 1;
|
||||
tracing::debug!(repo = %repo.repo, error = %e, attempt, "retrying forge request");
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(Error::Forge(e.to_string())),
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
|
||||
if status == StatusCode::NOT_MODIFIED {
|
||||
// Not an empty result: nothing changed. A caller that treated
|
||||
// this as "no issues" would abandon every job on every quiet
|
||||
// poll, so the distinction is carried in the type.
|
||||
return Ok(IssuePage {
|
||||
issues: Vec::new(),
|
||||
etag: repo.last_etag.clone(),
|
||||
not_modified: true,
|
||||
});
|
||||
}
|
||||
|
||||
if is_retryable(status) && attempt + 1 < MAX_ATTEMPTS {
|
||||
let retry_after = response
|
||||
.headers()
|
||||
.get(header::RETRY_AFTER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok());
|
||||
tokio::time::sleep(Self::backoff(attempt, retry_after)).await;
|
||||
attempt += 1;
|
||||
tracing::debug!(repo = %repo.repo, %status, attempt, "retrying forge request");
|
||||
continue;
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(Error::Forge(format!(
|
||||
"listing issues for {}/{}: {status}",
|
||||
repo.owner, repo.repo
|
||||
)));
|
||||
}
|
||||
|
||||
let etag = response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
|
||||
let raw: Vec<GiteaIssue> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| Error::Forge(format!("decoding issues: {e}")))?;
|
||||
|
||||
let issues = raw
|
||||
.into_iter()
|
||||
// Gitea's `type=issues` should have excluded pull requests, but
|
||||
// an older server ignores the parameter and returns both. A pull
|
||||
// request enqueued as an issue would be planned or implemented
|
||||
// as though it were one.
|
||||
.filter(|i| !i.is_pull_request())
|
||||
.map(|i| i.into_discovered(repo.forge, &repo.owner, &repo.repo))
|
||||
.collect();
|
||||
|
||||
return Ok(IssuePage {
|
||||
issues,
|
||||
etag,
|
||||
not_modified: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_label(&self, _issue: &IssueRef, _label: &str) -> Result<(), Error> {
|
||||
Err(unimplemented_write("add_label"))
|
||||
}
|
||||
|
||||
async fn remove_label(&self, _issue: &IssueRef, _label: &str) -> Result<(), Error> {
|
||||
Err(unimplemented_write("remove_label"))
|
||||
}
|
||||
|
||||
async fn comment(&self, _issue: &IssueRef, _body: &str) -> Result<(), Error> {
|
||||
Err(unimplemented_write("comment"))
|
||||
}
|
||||
|
||||
async fn create_issue(
|
||||
&self,
|
||||
_repo: &TrackedRepo,
|
||||
_title: &str,
|
||||
_body: &str,
|
||||
_labels: &[String],
|
||||
) -> Result<IssueRef, Error> {
|
||||
Err(unimplemented_write("create_issue"))
|
||||
}
|
||||
|
||||
async fn open_pull_request(
|
||||
&self,
|
||||
_repo: &TrackedRepo,
|
||||
_head: &str,
|
||||
_title: &str,
|
||||
_body: &str,
|
||||
) -> Result<PullRequestRef, Error> {
|
||||
Err(unimplemented_write("open_pull_request"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage 1 reads; it does not write.
|
||||
///
|
||||
/// An error rather than a silent success: stage 2 turns these on deliberately,
|
||||
/// after the claim protocol has been exercised by a dry-run executor, and a
|
||||
/// no-op that returned `Ok` would let that stage look finished while the forge
|
||||
/// saw nothing.
|
||||
fn unimplemented_write(what: &str) -> Error {
|
||||
Error::Forge(format!(
|
||||
"{what} is not implemented in stage 1: forge writes land in stage 2, \
|
||||
after the claim protocol has been exercised without them \
|
||||
(doc/plan/design.md §7)"
|
||||
))
|
||||
}
|
||||
|
||||
/// The subset of Gitea's issue payload tireless reads.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GiteaIssue {
|
||||
number: i64,
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
body: String,
|
||||
#[serde(default)]
|
||||
labels: Vec<GiteaLabel>,
|
||||
state: String,
|
||||
/// Present on pull requests, absent on issues. Gitea's `type=issues` filter
|
||||
/// should exclude them, but an older server ignores the parameter and
|
||||
/// silently returns both — and a pull request enqueued as an issue would be
|
||||
/// worked on as if it were one.
|
||||
#[serde(default)]
|
||||
pull_request: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GiteaLabel {
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl GiteaIssue {
|
||||
fn into_discovered(self, forge: Forge, owner: &str, repo: &str) -> DiscoveredIssue {
|
||||
DiscoveredIssue {
|
||||
issue: IssueRef {
|
||||
forge,
|
||||
owner: owner.to_string(),
|
||||
repo: repo.to_string(),
|
||||
number: self.number,
|
||||
},
|
||||
title: self.title,
|
||||
body: self.body,
|
||||
labels: self.labels.into_iter().map(|l| l.name).collect(),
|
||||
is_open: self.state == "open",
|
||||
}
|
||||
}
|
||||
|
||||
fn is_pull_request(&self) -> bool {
|
||||
self.pull_request.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GitHubClient {
|
||||
#[allow(dead_code)]
|
||||
http: Client,
|
||||
@@ -29,4 +291,110 @@ pub struct GitHubClient {
|
||||
token: String,
|
||||
}
|
||||
|
||||
// `ForgeClient` impls land in stage 1. See doc/plan/design.md §7.
|
||||
// `ForgeClient` for GitHub lands when a legacy repo actually needs it. It is
|
||||
// disabled in the shipped config, and an unused implementation is one more thing
|
||||
// to keep working for no one.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_bad_base_url_is_refused_at_construction() {
|
||||
assert!(GiteaClient::new("not a url", "t").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoints_are_built_under_the_api_root() {
|
||||
let c = GiteaClient::new("https://git.lair.cafe", "t").expect("client");
|
||||
assert_eq!(
|
||||
c.endpoint("repos/lair/tireless/issues")
|
||||
.expect("url")
|
||||
.as_str(),
|
||||
"https://git.lair.cafe/api/v1/repos/lair/tireless/issues"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_base_url_with_a_path_does_not_swallow_the_api_root() {
|
||||
// `Url::join` on a relative path would replace the last segment; the
|
||||
// absolute `/api/v1/...` form is why this works. Worth pinning, because
|
||||
// the failure is a 404 that looks like a missing repo.
|
||||
let c = GiteaClient::new("https://example.internal/gitea/", "t").expect("client");
|
||||
assert_eq!(
|
||||
c.endpoint("repos/a/b/issues").expect("url").as_str(),
|
||||
"https://example.internal/api/v1/repos/a/b/issues"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_throttling_and_server_faults_are_retried() {
|
||||
assert!(is_retryable(StatusCode::TOO_MANY_REQUESTS));
|
||||
assert!(is_retryable(StatusCode::BAD_GATEWAY));
|
||||
assert!(is_retryable(StatusCode::SERVICE_UNAVAILABLE));
|
||||
// A 404 or a 401 will not fix itself, and retrying spends the forge's
|
||||
// patience to learn nothing.
|
||||
assert!(!is_retryable(StatusCode::NOT_FOUND));
|
||||
assert!(!is_retryable(StatusCode::UNAUTHORIZED));
|
||||
assert!(!is_retryable(StatusCode::FORBIDDEN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_grows_and_respects_retry_after() {
|
||||
let a = GiteaClient::backoff(0, None);
|
||||
let b = GiteaClient::backoff(2, None);
|
||||
assert!(b > a, "backoff must grow with attempts");
|
||||
assert_eq!(
|
||||
GiteaClient::backoff(0, Some(30)),
|
||||
std::time::Duration::from_secs(30),
|
||||
"the forge's own Retry-After wins over our guess"
|
||||
);
|
||||
assert_eq!(
|
||||
GiteaClient::backoff(0, Some(9999)),
|
||||
std::time::Duration::from_secs(60),
|
||||
"but a hostile or absurd Retry-After is capped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_report_that_they_are_not_implemented() {
|
||||
// Rather than succeeding silently, which would make stage 2 look done.
|
||||
let e = unimplemented_write("comment");
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("not implemented in stage 1"), "{msg}");
|
||||
assert!(msg.contains("stage 2"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pull_request_is_recognisable_in_the_payload() {
|
||||
let pr: GiteaIssue = serde_json::from_str(
|
||||
r#"{"number":1,"title":"t","state":"open","pull_request":{"merged":false}}"#,
|
||||
)
|
||||
.expect("decode");
|
||||
assert!(pr.is_pull_request());
|
||||
|
||||
let issue: GiteaIssue =
|
||||
serde_json::from_str(r#"{"number":2,"title":"t","state":"open"}"#).expect("decode");
|
||||
assert!(!issue.is_pull_request());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_and_open_state_survive_the_mapping() {
|
||||
let raw: GiteaIssue = serde_json::from_str(
|
||||
r#"{"number":7,"title":"x","body":"b","state":"open",
|
||||
"labels":[{"name":"tireless"},{"name":"tireless/plan"}]}"#,
|
||||
)
|
||||
.expect("decode");
|
||||
let d = raw.into_discovered(Forge::Gitea, "lair", "tireless");
|
||||
assert_eq!(d.issue.number, 7);
|
||||
assert_eq!(d.labels, vec!["tireless", "tireless/plan"]);
|
||||
assert!(d.is_open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_issue_maps_as_closed() {
|
||||
let raw: GiteaIssue =
|
||||
serde_json::from_str(r#"{"number":1,"title":"x","state":"closed"}"#).expect("decode");
|
||||
assert!(!raw.into_discovered(Forge::Gitea, "a", "b").is_open);
|
||||
}
|
||||
}
|
||||
|
||||
356
crates/tireless-data/src/job_store.rs
Normal file
356
crates/tireless-data/src/job_store.rs
Normal file
@@ -0,0 +1,356 @@
|
||||
//! `JobStore` over Postgres.
|
||||
//!
|
||||
//! Claiming is a row transition under `SELECT … FOR UPDATE SKIP LOCKED`
|
||||
//! (`architecture/generic.md` §3), which makes it atomic across however many
|
||||
//! runners exist. Nothing here reads a forge label to decide anything —
|
||||
//! `CLAUDE.md` invariant 6.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use sqlx::{Row, postgres::PgRow};
|
||||
use tireless_core::port::{DiscoveredIssue, JobStore};
|
||||
use tireless_core::routing::{job_kind_for, lane_for};
|
||||
use tireless_entities::{
|
||||
AgentKind, Error, Forge, IssueRef, Job, JobKind, JobState, LabelProtocol, PollSchedule,
|
||||
TrackedRepo,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::store::PgStore;
|
||||
|
||||
fn db(e: sqlx::Error) -> Error {
|
||||
Error::Database(e.to_string())
|
||||
}
|
||||
|
||||
/// Parse a value written by serde back into its enum.
|
||||
///
|
||||
/// Round-tripping through serde rather than hand-writing a `match` is what makes
|
||||
/// the schema's check constraints and the Rust types provably the same
|
||||
/// vocabulary — the store tests assert the constraint lists, and this asserts
|
||||
/// the parse. A hand-written match would let the two drift apart silently.
|
||||
fn parse<T: serde::de::DeserializeOwned>(s: &str, what: &str) -> Result<T, Error> {
|
||||
serde_json::from_value(serde_json::Value::String(s.to_string())).map_err(|_| {
|
||||
Error::Database(format!(
|
||||
"column {what} holds {s:?}, which is not a value this build understands; \
|
||||
a migration added it without the matching enum variant"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn wire<T: serde::Serialize>(v: &T) -> String {
|
||||
serde_json::to_value(v)
|
||||
.ok()
|
||||
.and_then(|x| x.as_str().map(str::to_owned))
|
||||
.expect("domain enums serialise to strings")
|
||||
}
|
||||
|
||||
fn job_from_row(row: &PgRow) -> Result<Job, Error> {
|
||||
Ok(Job {
|
||||
id: row.try_get("id").map_err(db)?,
|
||||
issue: IssueRef {
|
||||
forge: parse::<Forge>(row.try_get("forge").map_err(db)?, "job.forge")?,
|
||||
owner: row.try_get("owner").map_err(db)?,
|
||||
repo: row.try_get("repo").map_err(db)?,
|
||||
number: row.try_get("number").map_err(db)?,
|
||||
},
|
||||
kind: parse::<JobKind>(row.try_get("kind").map_err(db)?, "job.kind")?,
|
||||
state: parse::<JobState>(row.try_get("state").map_err(db)?, "job.state")?,
|
||||
claimed_by: row.try_get("claimed_by").map_err(db)?,
|
||||
claim_expires_at: row.try_get("claim_expires_at").map_err(db)?,
|
||||
parent_job_id: row.try_get("parent_job_id").map_err(db)?,
|
||||
attempts: row.try_get("attempts").map_err(db)?,
|
||||
last_error: row.try_get("last_error").map_err(db)?,
|
||||
created_at: row.try_get("created_at").map_err(db)?,
|
||||
updated_at: row.try_get("updated_at").map_err(db)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn repo_from_row(row: &PgRow) -> Result<TrackedRepo, Error> {
|
||||
Ok(TrackedRepo {
|
||||
id: row.try_get("id").map_err(db)?,
|
||||
forge: parse::<Forge>(row.try_get("forge").map_err(db)?, "tracked_repo.forge")?,
|
||||
owner: row.try_get("owner").map_err(db)?,
|
||||
repo: row.try_get("repo").map_err(db)?,
|
||||
clone_url: row.try_get("clone_url").map_err(db)?,
|
||||
default_branch: row.try_get("default_branch").map_err(db)?,
|
||||
schedule: PollSchedule {
|
||||
interval_seconds: row.try_get::<i32, _>("interval_seconds").map_err(db)? as u32,
|
||||
quiet_from: row.try_get("quiet_from").map_err(db)?,
|
||||
quiet_until: row.try_get("quiet_until").map_err(db)?,
|
||||
enabled: row.try_get("enabled").map_err(db)?,
|
||||
},
|
||||
last_etag: row.try_get("last_etag").map_err(db)?,
|
||||
last_polled_at: row.try_get("last_polled_at").map_err(db)?,
|
||||
created_at: row.try_get("created_at").map_err(db)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// How long a claim is held before the lease expires.
|
||||
///
|
||||
/// Longer than any single agent run's ceiling would strand a job for hours after
|
||||
/// a crash; shorter than a run would let a second worker claim a job that is
|
||||
/// still being worked. The runner renews while it works, so this is the *gap
|
||||
/// after a worker stops renewing*, not a run budget.
|
||||
const CLAIM_LEASE: Duration = Duration::minutes(10);
|
||||
|
||||
#[async_trait]
|
||||
impl JobStore for PgStore {
|
||||
async fn tracked_repos(&self) -> Result<Vec<TrackedRepo>, Error> {
|
||||
let rows = sqlx::query(
|
||||
"select id, forge, owner, repo, clone_url, default_branch, interval_seconds, \
|
||||
quiet_from, quiet_until, enabled, last_etag, last_polled_at, created_at \
|
||||
from tracked_repo \
|
||||
where enabled \
|
||||
order by created_at",
|
||||
)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
rows.iter().map(repo_from_row).collect()
|
||||
}
|
||||
|
||||
async fn enqueue(&self, issues: &[DiscoveredIssue]) -> Result<usize, Error> {
|
||||
let mut enqueued = 0usize;
|
||||
|
||||
for issue in issues {
|
||||
// A closed issue is withdrawn work, whatever it is labelled.
|
||||
if !issue.is_open {
|
||||
continue;
|
||||
}
|
||||
// Labels decide what to do, and whether to do anything at all. An
|
||||
// issue that is not opted in never reaches here from a well-behaved
|
||||
// client, but enqueue is the last gate before work exists, so it
|
||||
// checks rather than assumes.
|
||||
let Some(kind) = job_kind_for(&issue.labels, &self.labels) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Recorded now because the labels that decide it are in hand now;
|
||||
// deriving it at claim time would cost a forge request per claim.
|
||||
let lane = lane_for(kind, false, &issue.labels, &self.labels).agent;
|
||||
|
||||
// `on conflict do nothing` against the partial unique index: an
|
||||
// issue that already has a live job is skipped, and one whose jobs
|
||||
// are all terminal gets a new one. That is the same rule the index
|
||||
// encodes, so the two cannot disagree.
|
||||
let result = sqlx::query(
|
||||
"insert into job (forge, owner, repo, number, kind, state, lane) \
|
||||
values ($1, $2, $3, $4, $5, 'pending', $6) \
|
||||
on conflict do nothing",
|
||||
)
|
||||
.bind(wire(&issue.issue.forge))
|
||||
.bind(&issue.issue.owner)
|
||||
.bind(&issue.issue.repo)
|
||||
.bind(issue.issue.number)
|
||||
.bind(wire(&kind))
|
||||
.bind(wire(&lane))
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
enqueued += result.rows_affected() as usize;
|
||||
}
|
||||
|
||||
Ok(enqueued)
|
||||
}
|
||||
|
||||
async fn claim_next(
|
||||
&self,
|
||||
worker: &str,
|
||||
allowed_lanes: &[AgentKind],
|
||||
) -> Result<Option<Job>, Error> {
|
||||
if allowed_lanes.is_empty() {
|
||||
// Every lane is held. Asking the database would be a round trip to
|
||||
// be told nothing, and an empty `any($1)` matches nothing anyway.
|
||||
return Ok(None);
|
||||
}
|
||||
let lanes: Vec<String> = allowed_lanes.iter().map(wire).collect();
|
||||
|
||||
// One statement, so the select and the update are the same transaction
|
||||
// without managing one by hand. The CTE takes the row lock with SKIP
|
||||
// LOCKED; the update writes the claim. A second claimer running
|
||||
// concurrently skips the locked row rather than blocking on it.
|
||||
let row = sqlx::query(
|
||||
"with candidate as ( \
|
||||
select id from job \
|
||||
where state = 'pending' and lane = any($1) \
|
||||
order by created_at \
|
||||
limit 1 \
|
||||
for update skip locked \
|
||||
) \
|
||||
update job set state = 'claimed', \
|
||||
claimed_by = $2, \
|
||||
claim_expires_at = now() + $3::interval, \
|
||||
attempts = attempts + 1, \
|
||||
updated_at = now() \
|
||||
from candidate \
|
||||
where job.id = candidate.id \
|
||||
returning job.id, job.forge, job.owner, job.repo, job.number, job.kind, \
|
||||
job.state, job.claimed_by, job.claim_expires_at, job.parent_job_id, \
|
||||
job.attempts, job.last_error, job.created_at, job.updated_at",
|
||||
)
|
||||
.bind(&lanes)
|
||||
.bind(worker)
|
||||
.bind(format!("{} seconds", CLAIM_LEASE.num_seconds()))
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
row.as_ref().map(job_from_row).transpose()
|
||||
}
|
||||
|
||||
async fn transition(&self, job: Uuid, to: JobState) -> Result<(), Error> {
|
||||
// A terminal job is never re-opened: re-running one is an operator
|
||||
// action that creates a *new* job, so that history is not rewritten
|
||||
// (design.md §4.3).
|
||||
let terminal: Vec<String> = [
|
||||
JobState::Delivered,
|
||||
JobState::Blocked,
|
||||
JobState::Failed,
|
||||
JobState::Abandoned,
|
||||
]
|
||||
.iter()
|
||||
.map(wire)
|
||||
.collect();
|
||||
|
||||
// Returning to pending must release the claim, or the row violates
|
||||
// `pending_holds_no_claim` — the database refuses to let this be
|
||||
// half-done, which is what makes lease expiry safe.
|
||||
let releasing = to == JobState::Pending;
|
||||
|
||||
let result = sqlx::query(
|
||||
"update job \
|
||||
set state = $2, \
|
||||
claimed_by = case when $3 then null else claimed_by end, \
|
||||
claim_expires_at = case when $3 then null else claim_expires_at end, \
|
||||
updated_at = now() \
|
||||
where id = $1 and state <> all($4)",
|
||||
)
|
||||
.bind(job)
|
||||
.bind(wire(&to))
|
||||
.bind(releasing)
|
||||
.bind(&terminal)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
// Either the job is gone or it is already terminal. Both are the
|
||||
// caller asking for something that cannot happen, and both are worth
|
||||
// surfacing rather than silently succeeding.
|
||||
return Err(Error::IllegalTransition(job));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn expire_stale_claims(&self) -> Result<usize, Error> {
|
||||
let result = sqlx::query(
|
||||
"update job \
|
||||
set state = 'pending', \
|
||||
claimed_by = null, \
|
||||
claim_expires_at = null, \
|
||||
last_error = coalesce(last_error, 'claim lease expired'), \
|
||||
updated_at = now() \
|
||||
where state in ('claimed', 'running') \
|
||||
and claim_expires_at < now()",
|
||||
)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
Ok(result.rows_affected() as usize)
|
||||
}
|
||||
}
|
||||
|
||||
impl PgStore {
|
||||
/// Renew a held claim, pushing its lease out.
|
||||
///
|
||||
/// A run can outlast `CLAIM_LEASE`, and without renewal the sweeper would
|
||||
/// hand a still-running job to a second worker — two agents on one issue,
|
||||
/// which the claim protocol exists to prevent. Guarded on `claimed_by` so a
|
||||
/// worker cannot renew a claim it lost while it was not looking.
|
||||
pub async fn renew_claim(&self, job: Uuid, worker: &str) -> Result<bool, Error> {
|
||||
let result = sqlx::query(
|
||||
"update job set claim_expires_at = now() + $3::interval, updated_at = now() \
|
||||
where id = $1 and claimed_by = $2 and state in ('claimed', 'running')",
|
||||
)
|
||||
.bind(job)
|
||||
.bind(worker)
|
||||
.bind(format!("{} seconds", CLAIM_LEASE.num_seconds()))
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
Ok(result.rows_affected() == 1)
|
||||
}
|
||||
|
||||
/// Record the labels a poll observed, refreshing the cached lane.
|
||||
///
|
||||
/// The lane is a cache of operator intent (see `0002_job_lane.sql`), so an
|
||||
/// operator adding `tireless/agent:oc` to a queued issue has to reach the
|
||||
/// job somehow. Only live jobs are touched: re-routing history would be
|
||||
/// rewriting what happened.
|
||||
pub async fn refresh_lane(
|
||||
&self,
|
||||
issue: &IssueRef,
|
||||
labels: &[String],
|
||||
protocol: &LabelProtocol,
|
||||
) -> Result<usize, Error> {
|
||||
let row = sqlx::query(
|
||||
"select id, kind, parent_job_id from job \
|
||||
where forge = $1 and owner = $2 and repo = $3 and number = $4 \
|
||||
and state in ('pending', 'claimed', 'running')",
|
||||
)
|
||||
.bind(wire(&issue.forge))
|
||||
.bind(&issue.owner)
|
||||
.bind(&issue.repo)
|
||||
.bind(issue.number)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
let Some(row) = row else { return Ok(0) };
|
||||
let kind: JobKind = parse(row.try_get("kind").map_err(db)?, "job.kind")?;
|
||||
let has_parent = row
|
||||
.try_get::<Option<Uuid>, _>("parent_job_id")
|
||||
.map_err(db)?
|
||||
.is_some();
|
||||
let lane = lane_for(kind, has_parent, labels, protocol).agent;
|
||||
|
||||
let result = sqlx::query("update job set lane = $2, updated_at = now() where id = $1")
|
||||
.bind(row.try_get::<Uuid, _>("id").map_err(db)?)
|
||||
.bind(wire(&lane))
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
Ok(result.rows_affected() as usize)
|
||||
}
|
||||
|
||||
/// When the most recent finished job of a kind ran against an issue.
|
||||
///
|
||||
/// The discovery cooldown asks this: a survey that ran this morning has
|
||||
/// nothing new to say this afternoon (design.md §2.6).
|
||||
pub async fn last_finished_at(
|
||||
&self,
|
||||
issue: &IssueRef,
|
||||
kind: JobKind,
|
||||
) -> Result<Option<DateTime<Utc>>, Error> {
|
||||
let row = sqlx::query(
|
||||
"select max(updated_at) as last from job \
|
||||
where forge = $1 and owner = $2 and repo = $3 and number = $4 \
|
||||
and kind = $5 and state = 'delivered'",
|
||||
)
|
||||
.bind(wire(&issue.forge))
|
||||
.bind(&issue.owner)
|
||||
.bind(&issue.repo)
|
||||
.bind(issue.number)
|
||||
.bind(wire(&kind))
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(db)?;
|
||||
|
||||
row.try_get("last").map_err(db)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! business logic — routing and budget decisions belong in core.
|
||||
|
||||
pub mod forge;
|
||||
pub mod job_store;
|
||||
pub mod store;
|
||||
|
||||
pub use forge::{GitHubClient, GiteaClient};
|
||||
|
||||
@@ -26,18 +26,25 @@
|
||||
|
||||
use sqlx::PgPool;
|
||||
use sqlx::migrate::Migrator;
|
||||
use tireless_entities::LabelProtocol;
|
||||
|
||||
/// Migrations, embedded at compile time from `migrations/`.
|
||||
pub static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
|
||||
|
||||
pub struct PgStore {
|
||||
#[allow(dead_code)]
|
||||
pool: PgPool,
|
||||
/// The label vocabulary this deployment uses.
|
||||
///
|
||||
/// Enqueue has to turn labels into a `JobKind` and a lane, and the protocol
|
||||
/// is configurable, so the store carries it rather than hard-coding the
|
||||
/// defaults. The *rules* stay in `tireless_core::routing`; this is only the
|
||||
/// vocabulary they are applied to.
|
||||
pub(crate) labels: LabelProtocol,
|
||||
}
|
||||
|
||||
impl PgStore {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
pub fn new(pool: PgPool, labels: LabelProtocol) -> Self {
|
||||
Self { pool, labels }
|
||||
}
|
||||
|
||||
/// Apply any outstanding migrations.
|
||||
@@ -55,9 +62,6 @@ impl PgStore {
|
||||
}
|
||||
}
|
||||
|
||||
// The `JobStore` impl lands with the next child of this epic. See
|
||||
// doc/plan/design.md §7.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -210,10 +214,49 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_one_migration_ships_today() {
|
||||
// A reminder rather than a rule: schema changes are new files, never
|
||||
// edits to an applied one, because sqlx records a checksum per version.
|
||||
assert_eq!(MIGRATOR.iter().count(), 1);
|
||||
assert_eq!(MIGRATOR.iter().next().expect("first").version, 1);
|
||||
fn migration_versions_are_unique_and_ascending() {
|
||||
// Not a count — that only fails on every legitimate migration, which
|
||||
// teaches people to edit the assertion rather than think. What matters
|
||||
// is that versions are distinct and ordered: sqlx applies them in
|
||||
// version order and records a checksum per version, so a duplicate or
|
||||
// out-of-order version applies the wrong file or refuses to apply at all.
|
||||
let versions: Vec<i64> = MIGRATOR.iter().map(|m| m.version).collect();
|
||||
assert!(
|
||||
!versions.is_empty(),
|
||||
"at least the initial schema must ship"
|
||||
);
|
||||
|
||||
let mut sorted = versions.clone();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
assert_eq!(
|
||||
sorted, versions,
|
||||
"migration versions must be unique and already in ascending order"
|
||||
);
|
||||
assert_eq!(versions[0], 1, "the initial schema is version 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_claim_index_covers_the_lane_filter() {
|
||||
// `claim_next` filters on lane, and 0002 replaced the 0001 index to
|
||||
// support it. If a later migration drops that index without a
|
||||
// replacement, claiming silently degrades to a sequential scan of every
|
||||
// pending job on every claim — slow in a way that looks like the forge
|
||||
// being slow.
|
||||
const LANE_SQL: &str = include_str!("../migrations/0002_job_lane.sql");
|
||||
assert!(LANE_SQL.contains("create index job_claimable_idx"));
|
||||
assert!(LANE_SQL.contains("on job (lane, created_at)"));
|
||||
assert!(LANE_SQL.contains("where state = 'pending'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lane_column_accepts_exactly_the_agent_kinds() {
|
||||
const LANE_SQL: &str = include_str!("../migrations/0002_job_lane.sql");
|
||||
for a in [AgentKind::ClaudeCode, AgentKind::Opencode] {
|
||||
assert!(
|
||||
LANE_SQL.contains(&format!("'{}'", wire(&a))),
|
||||
"lane check constraint is missing {a:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
322
crates/tireless-data/tests/gitea_client.rs
Normal file
322
crates/tireless-data/tests/gitea_client.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
//! `GiteaClient` against a mock forge.
|
||||
//!
|
||||
//! These need no database and run in the normal `cargo test`, unlike the
|
||||
//! migration tests. The behaviour under test is entirely about being a polite
|
||||
//! API client — conditional requests, and backing off rather than hammering —
|
||||
//! which is exactly the part that is invisible when it goes wrong: an
|
||||
//! unconditional poller and a conditional one look identical from the outside
|
||||
//! until someone reads the forge's access log.
|
||||
|
||||
use chrono::Utc;
|
||||
use tireless_core::port::ForgeClient;
|
||||
use tireless_data::GiteaClient;
|
||||
use tireless_entities::{Forge, PollSchedule, TrackedRepo};
|
||||
use uuid::Uuid;
|
||||
use wiremock::matchers::{header, header_exists, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn repo(base_etag: Option<&str>) -> TrackedRepo {
|
||||
TrackedRepo {
|
||||
id: Uuid::new_v4(),
|
||||
forge: Forge::Gitea,
|
||||
owner: "lair".into(),
|
||||
repo: "tireless".into(),
|
||||
clone_url: "gitea@git.internal:lair/tireless.git".into(),
|
||||
default_branch: "main".into(),
|
||||
schedule: PollSchedule::default(),
|
||||
last_etag: base_etag.map(str::to_owned),
|
||||
last_polled_at: None,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
const ISSUES_PATH: &str = "/api/v1/repos/lair/tireless/issues";
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_stored_etag_is_sent_as_if_none_match() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(ISSUES_PATH))
|
||||
.and(header("if-none-match", "\"abc123\""))
|
||||
.respond_with(ResponseTemplate::new(304))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GiteaClient::new(&server.uri(), "token").expect("client");
|
||||
let page = client
|
||||
.list_opted_in_issues(&repo(Some("\"abc123\"")))
|
||||
.await
|
||||
.expect("list");
|
||||
|
||||
assert!(page.not_modified, "304 must be reported as not-modified");
|
||||
assert!(page.issues.is_empty());
|
||||
// The mock's `.expect(1)` asserts on drop that the conditional header was
|
||||
// actually sent — without it the request would not have matched at all.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_modified_is_distinguishable_from_an_empty_repo() {
|
||||
// The distinction the `IssuePage` type exists for. A poller that conflated
|
||||
// them would treat every quiet poll as "every issue disappeared".
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(ISSUES_PATH))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GiteaClient::new(&server.uri(), "token").expect("client");
|
||||
let page = client
|
||||
.list_opted_in_issues(&repo(None))
|
||||
.await
|
||||
.expect("list");
|
||||
|
||||
assert!(page.issues.is_empty());
|
||||
assert!(
|
||||
!page.not_modified,
|
||||
"a genuinely empty repo is not a 304, and the caller must be able to tell"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_stored_etag_means_no_conditional_header() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(ISSUES_PATH))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// A first poll has nothing to be conditional on. If this sent a stale or
|
||||
// empty If-None-Match the forge could answer 304 to a client that has never
|
||||
// seen the issues.
|
||||
let client = GiteaClient::new(&server.uri(), "token").expect("client");
|
||||
client
|
||||
.list_opted_in_issues(&repo(None))
|
||||
.await
|
||||
.expect("list");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_returned_etag_is_surfaced_for_the_next_poll() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(ISSUES_PATH))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("etag", "\"v2\"")
|
||||
.set_body_json(serde_json::json!([])),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GiteaClient::new(&server.uri(), "token").expect("client");
|
||||
let page = client
|
||||
.list_opted_in_issues(&repo(None))
|
||||
.await
|
||||
.expect("list");
|
||||
|
||||
assert_eq!(
|
||||
page.etag.as_deref(),
|
||||
Some("\"v2\""),
|
||||
"without this the next poll cannot be conditional, and every poll refetches in full"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_token_is_sent_as_a_gitea_token_header() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(ISSUES_PATH))
|
||||
.and(header("authorization", "token s3cret"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GiteaClient::new(&server.uri(), "s3cret").expect("client");
|
||||
client
|
||||
.list_opted_in_issues(&repo(None))
|
||||
.await
|
||||
.expect("list");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_429_is_retried_and_then_given_up_on() {
|
||||
let server = MockServer::start().await;
|
||||
// Always throttled. The client must try a bounded number of times and then
|
||||
// return, rather than looping until the poll interval or forever.
|
||||
Mock::given(method("GET"))
|
||||
.and(path(ISSUES_PATH))
|
||||
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
|
||||
.expect(3)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GiteaClient::new(&server.uri(), "token").expect("client");
|
||||
let err = client
|
||||
.list_opted_in_issues(&repo(None))
|
||||
.await
|
||||
.expect_err("a permanently throttled forge is an error, not an empty result");
|
||||
assert!(err.to_string().contains("429"), "{err}");
|
||||
// `.expect(3)` asserts the attempt budget on drop: retried, but bounded.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_500_is_retried_and_a_later_success_is_returned() {
|
||||
let server = MockServer::start().await;
|
||||
// Fail twice, then succeed — the transient case retrying exists for.
|
||||
Mock::given(method("GET"))
|
||||
.and(path(ISSUES_PATH))
|
||||
.respond_with(ResponseTemplate::new(503))
|
||||
.up_to_n_times(2)
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(ISSUES_PATH))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
|
||||
{"number": 4, "title": "x", "state": "open",
|
||||
"labels": [{"name": "tireless"}, {"name": "tireless/plan"}]}
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GiteaClient::new(&server.uri(), "token").expect("client");
|
||||
let page = client
|
||||
.list_opted_in_issues(&repo(None))
|
||||
.await
|
||||
.expect("list");
|
||||
assert_eq!(page.issues.len(), 1);
|
||||
assert_eq!(page.issues[0].issue.number, 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_404_is_not_retried() {
|
||||
let server = MockServer::start().await;
|
||||
// A repo that does not exist, or a token that cannot see it, will not fix
|
||||
// itself. Retrying spends the forge's patience to learn nothing.
|
||||
Mock::given(method("GET"))
|
||||
.and(path(ISSUES_PATH))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GiteaClient::new(&server.uri(), "token").expect("client");
|
||||
assert!(client.list_opted_in_issues(&repo(None)).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pull_requests_are_filtered_out_of_the_issue_list() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(ISSUES_PATH))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
|
||||
{"number": 1, "title": "an issue", "state": "open", "labels": []},
|
||||
{"number": 2, "title": "a pull request", "state": "open", "labels": [],
|
||||
"pull_request": {"merged": false}}
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GiteaClient::new(&server.uri(), "token").expect("client");
|
||||
let page = client
|
||||
.list_opted_in_issues(&repo(None))
|
||||
.await
|
||||
.expect("list");
|
||||
|
||||
assert_eq!(
|
||||
page.issues.len(),
|
||||
1,
|
||||
"a pull request must not be enqueued as an issue"
|
||||
);
|
||||
assert_eq!(page.issues[0].issue.number, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn labels_and_body_reach_the_caller() {
|
||||
// The router reads labels, and the planner reads the body. Dropping either
|
||||
// in the mapping produces work that is silently wrong rather than absent.
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(ISSUES_PATH))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
|
||||
{"number": 9, "title": "t", "body": "the description", "state": "open",
|
||||
"labels": [{"name": "tireless"}, {"name": "tireless/agent:oc"}]}
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GiteaClient::new(&server.uri(), "token").expect("client");
|
||||
let page = client
|
||||
.list_opted_in_issues(&repo(None))
|
||||
.await
|
||||
.expect("list");
|
||||
let issue = &page.issues[0];
|
||||
|
||||
assert_eq!(issue.body, "the description");
|
||||
assert_eq!(issue.labels, vec!["tireless", "tireless/agent:oc"]);
|
||||
assert_eq!(issue.issue.owner, "lair");
|
||||
assert_eq!(issue.issue.repo, "tireless");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_request_asks_only_for_open_issues() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(ISSUES_PATH))
|
||||
.and(header_exists("authorization"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GiteaClient::new(&server.uri(), "token").expect("client");
|
||||
client
|
||||
.list_opted_in_issues(&repo(None))
|
||||
.await
|
||||
.expect("list");
|
||||
|
||||
let requests = server.received_requests().await.expect("requests");
|
||||
let query = requests[0].url.query().unwrap_or_default();
|
||||
assert!(query.contains("state=open"), "query was {query:?}");
|
||||
assert!(query.contains("type=issues"), "query was {query:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_write_reports_that_it_is_not_implemented() {
|
||||
// Stage 1 reads. If any of these ever returns Ok without doing the work,
|
||||
// stage 2 looks finished while the forge sees nothing.
|
||||
use tireless_entities::IssueRef;
|
||||
let server = MockServer::start().await;
|
||||
let client = GiteaClient::new(&server.uri(), "token").expect("client");
|
||||
let issue = IssueRef {
|
||||
forge: Forge::Gitea,
|
||||
owner: "lair".into(),
|
||||
repo: "tireless".into(),
|
||||
number: 1,
|
||||
};
|
||||
|
||||
assert!(client.add_label(&issue, "x").await.is_err());
|
||||
assert!(client.remove_label(&issue, "x").await.is_err());
|
||||
assert!(client.comment(&issue, "x").await.is_err());
|
||||
assert!(
|
||||
client
|
||||
.create_issue(&repo(None), "t", "b", &[])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.open_pull_request(&repo(None), "h", "t", "b")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
server.received_requests().await.expect("requests").len(),
|
||||
0,
|
||||
"a not-implemented write must not reach the forge at all"
|
||||
);
|
||||
}
|
||||
@@ -216,3 +216,317 @@ async fn concurrent_claims_never_hand_out_the_same_job() {
|
||||
ta.commit().await.expect("commit a");
|
||||
tb.commit().await.expect("commit b");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JobStore behaviour.
|
||||
//
|
||||
// The claim protocol is the part of the design most likely to have subtle bugs
|
||||
// and the last place they are free (design.md §7, stage 2). These exercise it
|
||||
// against a real server rather than a mock, because every property that matters
|
||||
// here — atomicity, lease expiry, the partial index — is the database's.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use tireless_core::port::{DiscoveredIssue, JobStore};
|
||||
use tireless_data::PgStore;
|
||||
use tireless_entities::{AgentKind, Forge, IssueRef, JobState, LabelProtocol};
|
||||
|
||||
/// A store bound to its own schema, so these run concurrently with each other.
|
||||
async fn store(schema: &str) -> PgStore {
|
||||
let mut conn = fresh_db(schema).await;
|
||||
MIGRATOR.run(&mut conn).await.expect("migrate");
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(4)
|
||||
.after_connect({
|
||||
let schema = schema.to_string();
|
||||
move |c, _| {
|
||||
let schema = schema.clone();
|
||||
Box::pin(async move {
|
||||
sqlx::query(&format!("set search_path to {schema}"))
|
||||
.execute(&mut *c)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
})
|
||||
.connect(&url())
|
||||
.await
|
||||
.expect("pool");
|
||||
PgStore::new(pool, LabelProtocol::default())
|
||||
}
|
||||
|
||||
fn discovered(number: i64, labels: &[&str]) -> DiscoveredIssue {
|
||||
DiscoveredIssue {
|
||||
issue: IssueRef {
|
||||
forge: Forge::Gitea,
|
||||
owner: "lair".into(),
|
||||
repo: "tireless".into(),
|
||||
number,
|
||||
},
|
||||
title: format!("issue {number}"),
|
||||
body: String::new(),
|
||||
labels: labels.iter().map(|s| s.to_string()).collect(),
|
||||
is_open: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs TIRELESS_TEST_DATABASE_URL"]
|
||||
async fn enqueue_ignores_issues_that_express_no_work() {
|
||||
let s = store("t_enqueue").await;
|
||||
let p = LabelProtocol::default();
|
||||
|
||||
let n = s
|
||||
.enqueue(&[
|
||||
// opted in, no mode label — nothing asked for
|
||||
discovered(1, &[&p.opt_in]),
|
||||
// mode label, not opted in — a discovery proposal awaiting a human
|
||||
discovered(2, &[&p.mode_implement]),
|
||||
// neither
|
||||
discovered(3, &[]),
|
||||
// the real thing
|
||||
discovered(4, &[&p.opt_in, &p.mode_plan]),
|
||||
])
|
||||
.await
|
||||
.expect("enqueue");
|
||||
|
||||
assert_eq!(n, 1, "only the opted-in issue with a mode label enqueues");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs TIRELESS_TEST_DATABASE_URL"]
|
||||
async fn enqueue_is_idempotent_while_a_job_is_live() {
|
||||
let s = store("t_idem").await;
|
||||
let p = LabelProtocol::default();
|
||||
let issue = discovered(1, &[&p.opt_in, &p.mode_plan]);
|
||||
|
||||
assert_eq!(
|
||||
s.enqueue(std::slice::from_ref(&issue))
|
||||
.await
|
||||
.expect("first"),
|
||||
1
|
||||
);
|
||||
// Every poll re-reports the same open issue. Enqueue has to be a no-op, or
|
||||
// a repo polled every five minutes accumulates a job every five minutes.
|
||||
assert_eq!(
|
||||
s.enqueue(std::slice::from_ref(&issue))
|
||||
.await
|
||||
.expect("second"),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
s.enqueue(std::slice::from_ref(&issue))
|
||||
.await
|
||||
.expect("third"),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs TIRELESS_TEST_DATABASE_URL"]
|
||||
async fn a_closed_issue_is_never_enqueued() {
|
||||
let s = store("t_closed").await;
|
||||
let p = LabelProtocol::default();
|
||||
let mut issue = discovered(1, &[&p.opt_in, &p.mode_implement]);
|
||||
issue.is_open = false;
|
||||
assert_eq!(s.enqueue(&[issue]).await.expect("enqueue"), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs TIRELESS_TEST_DATABASE_URL"]
|
||||
async fn claiming_respects_the_lane_the_caller_allows() {
|
||||
let s = store("t_lane").await;
|
||||
let p = LabelProtocol::default();
|
||||
|
||||
// Plan always routes to Claude Code; the oc override forces the other lane.
|
||||
s.enqueue(&[
|
||||
discovered(1, &[&p.opt_in, &p.mode_plan]),
|
||||
discovered(2, &[&p.opt_in, &p.mode_plan, &p.force_oc]),
|
||||
])
|
||||
.await
|
||||
.expect("enqueue");
|
||||
|
||||
let oc = s
|
||||
.claim_next("w1", &[AgentKind::Opencode])
|
||||
.await
|
||||
.expect("claim oc")
|
||||
.expect("an opencode job is available");
|
||||
assert_eq!(oc.issue.number, 2, "the forced-oc job is the opencode one");
|
||||
|
||||
let cc = s
|
||||
.claim_next("w2", &[AgentKind::ClaudeCode])
|
||||
.await
|
||||
.expect("claim cc")
|
||||
.expect("a claude code job is available");
|
||||
assert_eq!(cc.issue.number, 1);
|
||||
|
||||
// Both lanes are now empty.
|
||||
assert!(
|
||||
s.claim_next("w3", &[AgentKind::ClaudeCode, AgentKind::Opencode])
|
||||
.await
|
||||
.expect("claim")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs TIRELESS_TEST_DATABASE_URL"]
|
||||
async fn claiming_with_no_allowed_lanes_returns_nothing() {
|
||||
// Every lane held by the governor. This must not hand out work.
|
||||
let s = store("t_nolane").await;
|
||||
let p = LabelProtocol::default();
|
||||
s.enqueue(&[discovered(1, &[&p.opt_in, &p.mode_plan])])
|
||||
.await
|
||||
.expect("enqueue");
|
||||
assert!(s.claim_next("w1", &[]).await.expect("claim").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs TIRELESS_TEST_DATABASE_URL"]
|
||||
async fn a_claim_sets_the_worker_and_a_lease_and_counts_the_attempt() {
|
||||
let s = store("t_claimmeta").await;
|
||||
let p = LabelProtocol::default();
|
||||
s.enqueue(&[discovered(1, &[&p.opt_in, &p.mode_plan])])
|
||||
.await
|
||||
.expect("enqueue");
|
||||
|
||||
let job = s
|
||||
.claim_next("worker-a", &[AgentKind::ClaudeCode])
|
||||
.await
|
||||
.expect("claim")
|
||||
.expect("job");
|
||||
|
||||
assert_eq!(job.state, JobState::Claimed);
|
||||
assert_eq!(job.claimed_by.as_deref(), Some("worker-a"));
|
||||
assert!(
|
||||
job.claim_expires_at.is_some(),
|
||||
"a claim without a lease can never expire"
|
||||
);
|
||||
assert_eq!(
|
||||
job.attempts, 1,
|
||||
"the attempt is counted at claim, not at completion"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs TIRELESS_TEST_DATABASE_URL"]
|
||||
async fn an_expired_lease_returns_the_job_and_clears_the_claim() {
|
||||
let s = store("t_expiry").await;
|
||||
let p = LabelProtocol::default();
|
||||
s.enqueue(&[discovered(1, &[&p.opt_in, &p.mode_plan])])
|
||||
.await
|
||||
.expect("enqueue");
|
||||
let job = s
|
||||
.claim_next("dead-worker", &[AgentKind::ClaudeCode])
|
||||
.await
|
||||
.expect("claim")
|
||||
.expect("job");
|
||||
|
||||
// A live lease is left alone.
|
||||
assert_eq!(s.expire_stale_claims().await.expect("sweep"), 0);
|
||||
|
||||
sqlx::query("update job set claim_expires_at = now() - interval '1 minute' where id = $1")
|
||||
.bind(job.id)
|
||||
.execute(s.pool())
|
||||
.await
|
||||
.expect("age the lease");
|
||||
|
||||
assert_eq!(s.expire_stale_claims().await.expect("sweep"), 1);
|
||||
|
||||
// And it is claimable again — which only works if the claim was cleared,
|
||||
// since `pending_holds_no_claim` would otherwise have rejected the update.
|
||||
let again = s
|
||||
.claim_next("live-worker", &[AgentKind::ClaudeCode])
|
||||
.await
|
||||
.expect("reclaim")
|
||||
.expect("the job returned to the pool");
|
||||
assert_eq!(again.claimed_by.as_deref(), Some("live-worker"));
|
||||
assert_eq!(
|
||||
again.attempts, 2,
|
||||
"the retry is visible in the attempt count"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs TIRELESS_TEST_DATABASE_URL"]
|
||||
async fn a_terminal_job_cannot_be_transitioned() {
|
||||
let s = store("t_terminal").await;
|
||||
let p = LabelProtocol::default();
|
||||
s.enqueue(&[discovered(1, &[&p.opt_in, &p.mode_plan])])
|
||||
.await
|
||||
.expect("enqueue");
|
||||
let job = s
|
||||
.claim_next("w1", &[AgentKind::ClaudeCode])
|
||||
.await
|
||||
.expect("claim")
|
||||
.expect("job");
|
||||
|
||||
s.transition(job.id, JobState::Delivered)
|
||||
.await
|
||||
.expect("deliver");
|
||||
|
||||
// Re-running a delivered job creates a new one; it never reopens the old,
|
||||
// so history stays true (design.md §4.3).
|
||||
let err = s.transition(job.id, JobState::Pending).await;
|
||||
assert!(err.is_err(), "a delivered job must not be reopened");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs TIRELESS_TEST_DATABASE_URL"]
|
||||
async fn renewing_a_claim_requires_still_holding_it() {
|
||||
let s = store("t_renew").await;
|
||||
let p = LabelProtocol::default();
|
||||
s.enqueue(&[discovered(1, &[&p.opt_in, &p.mode_plan])])
|
||||
.await
|
||||
.expect("enqueue");
|
||||
let job = s
|
||||
.claim_next("w1", &[AgentKind::ClaudeCode])
|
||||
.await
|
||||
.expect("claim")
|
||||
.expect("job");
|
||||
|
||||
assert!(
|
||||
s.renew_claim(job.id, "w1").await.expect("renew"),
|
||||
"the holder may renew"
|
||||
);
|
||||
assert!(
|
||||
!s.renew_claim(job.id, "w2").await.expect("renew"),
|
||||
"a worker that does not hold the claim must not be able to extend it"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs TIRELESS_TEST_DATABASE_URL"]
|
||||
async fn a_label_change_reroutes_a_queued_job() {
|
||||
// An operator adding tireless/agent:oc to something already queued has to
|
||||
// reach it, or the override only works before the poller sees the issue.
|
||||
let s = store("t_reroute").await;
|
||||
let p = LabelProtocol::default();
|
||||
let issue = discovered(1, &[&p.opt_in, &p.mode_plan]);
|
||||
s.enqueue(std::slice::from_ref(&issue))
|
||||
.await
|
||||
.expect("enqueue");
|
||||
|
||||
assert!(
|
||||
s.claim_next("w", &[AgentKind::Opencode])
|
||||
.await
|
||||
.expect("claim")
|
||||
.is_none(),
|
||||
"it starts on the claude code lane"
|
||||
);
|
||||
|
||||
let relabelled = vec![p.opt_in.clone(), p.mode_plan.clone(), p.force_oc.clone()];
|
||||
assert_eq!(
|
||||
s.refresh_lane(&issue.issue, &relabelled, &p)
|
||||
.await
|
||||
.expect("refresh"),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
s.claim_next("w", &[AgentKind::Opencode])
|
||||
.await
|
||||
.expect("claim")
|
||||
.is_some(),
|
||||
"after the relabel it belongs to opencode"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ pub enum Error {
|
||||
#[error("forge request failed: {0}")]
|
||||
Forge(String),
|
||||
|
||||
#[error("database error: {0}")]
|
||||
Database(String),
|
||||
|
||||
#[error("agent run failed: {0}")]
|
||||
Agent(String),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user