use std::collections::BTreeMap;
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use jiff::{SignedDuration, Timestamp};
use magi::ask::Questions;
use magi::config::Config;
use magi::land::{self, Blocking, Checks, PrLifecycle, PrState};
use magi::queue::{Queue, Source, Task, TaskStatus};
use magi::run::{
Candidate, Judgement, LandApproval, MergeOutcome, Origin, PrRecord, ReviewRecord, ReviewRound,
RunState, RunStatus, StartedBy, Tally,
};
use magi::verdict::{Finding, ReviewVote, Severity};
struct Content {
request: &'static str,
subject: &'static str,
summaries: [&'static str; 3],
reasons: [[&'static str; 3]; 3],
finding_title: &'static str,
finding_detail: &'static str,
review_backoff: &'static str,
review_reads_well: &'static str,
review_fixed: &'static str,
review_nothing: &'static str,
fix_notes: &'static str,
event_implement: &'static str,
event_review: &'static str,
event_opened: &'static str,
event_merged: &'static str,
question_summary: &'static str,
question_detail: &'static str,
question_choices: [&'static str; 2],
commits: [&'static str; 2],
approval_summary: &'static str,
approval_detail: &'static str,
language: &'static str,
}
const EN: Content = Content {
request: "add retry with backoff to the uploader",
subject: "feat(uploader): retry failed uploads with exponential backoff",
summaries: [
"Retry loop inside `upload`, fixed 1s delay, three attempts.",
"Exponential backoff with jitter in its own module, transient errors only.",
"Wraps the client in a generic retry middleware with a config knob.",
],
reasons: [
[
"Backoff is isolated and tested; only transient errors retry.",
"Sound, but the middleware is more surface than the task asks for.",
"A fixed delay hammers a struggling server.",
],
[
"Smallest correct design with a real test.",
"Simple, but no jitter and no test.",
"Over-engineered for one call site.",
],
[
"Jitter and a cap: what the task meant by backoff.",
"Reasonable, heavy.",
"Fixed delay, untested.",
],
],
finding_title: "Permanent errors are retried",
finding_detail: "A permanent error is retried as if it were transient.",
review_backoff: "Backoff is right, but a 4xx must not be retried.",
review_reads_well: "Reads well.",
review_fixed: "The finding is fixed.",
review_nothing: "Nothing further.",
fix_notes: "Retry only when the error reports itself transient.",
event_implement: "3 candidates ready",
event_review: "round 2 clean: 2 of 2 approve",
event_opened: "opened pull request #{n}",
event_merged: "merged pull request #{n}",
question_summary: "Postgres or SQLite for the cache?",
question_detail: "The uploader keeps a small table of in-flight uploads. SQLite needs no server \
and fits a single host; Postgres survives several workers.",
question_choices: ["SQLite", "Postgres"],
commits: [
"feat(uploader): add backoff module",
"fix(uploader): retry transient errors only",
],
approval_summary: "merge pull request #{n}: {subject}",
approval_detail: "{url} is green and ready to squash into `main` as `{subject}`. \
The panel holds the diffstat, the patch and the commits being squashed.",
language: "en",
};
const JA: Content = Content {
request: "アップローダーに指数バックオフ付きのリトライを追加して",
subject: "feat(uploader): 失敗したアップロードを指数バックオフで再試行する",
summaries: [
"`upload` 内の再試行ループ。待ち時間は 1 秒固定で 3 回まで。",
"ジッター付き指数バックオフを専用モジュールに分離。一時的なエラーのみ再試行。",
"クライアントを汎用の再試行ミドルウェアで包み、設定項目を用意。",
],
reasons: [
[
"バックオフが独立していてテストもあり、再試行は一時的なエラーだけ。",
"堅実だが、ミドルウェアはタスクが求める範囲より大きい。",
"固定の待ち時間では、不調なサーバーに負荷をかけ続ける。",
],
[
"本物のテストを備えた、最小で正しい設計。",
"単純だが、ジッターもテストもない。",
"呼び出し箇所が 1 つなのに作り込みすぎ。",
],
[
"ジッターと上限あり。バックオフの意図どおり。",
"妥当だが重い。",
"固定の待ち時間でテストなし。",
],
],
finding_title: "恒久的なエラーまで再試行している",
finding_detail: "恒久的なエラーを一時的なエラーと同じように再試行している。",
review_backoff: "バックオフは正しいが、4xx は再試行してはいけない。",
review_reads_well: "読みやすい。",
review_fixed: "指摘は修正された。",
review_nothing: "追加の指摘なし。",
fix_notes: "エラー自身が一時的だと示した場合だけ再試行する。",
event_implement: "候補 3 件が完成",
event_review: "ラウンド 2 は指摘なし: 2 人中 2 人が承認",
event_opened: "プルリクエスト #{n} を作成",
event_merged: "プルリクエスト #{n} をマージ",
question_summary: "キャッシュは Postgres と SQLite のどちらにしますか?",
question_detail: "アップローダーは処理中のアップロードを小さなテーブルで管理します。\
SQLite はサーバー不要で単一ホスト向き、Postgres は複数ワーカーでも耐えられます。",
question_choices: ["SQLite", "Postgres"],
commits: [
"feat(uploader): バックオフのモジュールを追加",
"fix(uploader): 一時的なエラーだけ再試行する",
],
approval_summary: "プルリクエスト #{n} をマージ: {subject}",
approval_detail: "{url} は green で、`main` に `{subject}` として squash できます。\
パネルに差分の統計、パッチ、squash されるコミットがあります。",
language: "ja",
};
fn content(lang: &str) -> Result<&'static Content> {
match lang {
"en" => Ok(&EN),
"ja" => Ok(&JA),
other => bail!("unknown language {other:?} (en | ja)"),
}
}
const PR_NUMBER: u64 = 42;
const PR_URL: &str = "https://github.com/example/uploader/pull/42";
const NUMSTAT: &str = "38\t4\tsrc/uploader.rs\n21\t0\tsrc/backoff.rs\n17\t0\ttests/retry.rs\n";
const DIFF: &str = "\
diff --git a/src/backoff.rs b/src/backoff.rs
new file mode 100644
--- /dev/null
+++ b/src/backoff.rs
@@ -0,0 +1,9 @@
+use std::time::Duration;
+
+/// Delay before attempt `n` (0-based): 200ms doubling, capped at 10s, with jitter.
+pub fn delay(n: u32, jitter: f64) -> Duration {
+ let base = 200u64.saturating_mul(1u64 << n.min(16));
+ let capped = base.min(10_000) as f64;
+ Duration::from_millis((capped * (0.5 + jitter / 2.0)) as u64)
+}
diff --git a/src/uploader.rs b/src/uploader.rs
--- a/src/uploader.rs
+++ b/src/uploader.rs
@@ -21,7 +21,14 @@ impl Uploader {
- self.client.put(url, body).await
+ let mut attempt = 0;
+ loop {
+ match self.client.put(url, body.clone()).await {
+ Err(e) if e.is_transient() && attempt < MAX_ATTEMPTS => {
+ tokio::time::sleep(backoff::delay(attempt, rand::random())).await;
+ attempt += 1;
+ }
+ other => return other,
+ }
+ }
";
fn ago(minutes: i64) -> Timestamp {
Timestamp::now() - SignedDuration::from_mins(minutes)
}
fn fill(template: &str, c: &Content) -> String {
template
.replace("{n}", &PR_NUMBER.to_string())
.replace("{url}", PR_URL)
.replace("{subject}", c.subject)
}
fn candidate(label: char, summary: &str, stat: &str, files: usize, secs: u64) -> Candidate {
Candidate {
index: (label as usize) - ('A' as usize),
label,
agent: String::new(),
branch: format!("magi/demo/{label}"),
worktree: PathBuf::new(),
summary: summary.to_owned(),
stat: stat.to_owned(),
files,
commits: 1,
empty: false,
failed: None,
verified_noop: None,
duration_ms: secs * 1000,
folded: false,
}
}
fn judgement(judge: usize, ranking: [char; 3], why: [&str; 3]) -> Judgement {
Judgement {
judge,
seat: format!("judge-{judge}"),
agent: String::new(),
reasons: ranking
.iter()
.zip(why)
.map(|(l, w)| (l.to_string(), w.to_owned()))
.collect::<BTreeMap<_, _>>(),
ranking: ranking.to_vec(),
confidence: Some(8),
strengths: BTreeMap::new(),
order: vec![0, 1, 2],
failed: None,
duration_ms: 41_000,
}
}
fn finding(
id: &str,
severity: Severity,
file: &str,
line: u32,
title: &str,
detail: &str,
) -> Finding {
Finding {
id: id.to_owned(),
severity,
file: Some(file.to_owned()),
line: Some(line),
title: title.to_owned(),
detail: detail.to_owned(),
}
}
fn review(
reviewer: usize,
summary: &str,
findings: Vec<Finding>,
vote: ReviewVote,
) -> ReviewRecord {
ReviewRecord {
reviewer,
agent: String::new(),
summary: summary.to_owned(),
findings,
vote: Some(vote),
failed: None,
duration_ms: 52_000,
attempts: 0,
}
}
fn build(id: &str, repo: &std::path::Path, stage: &str, c: &Content) -> RunState {
let mut config = Config::default();
config.graph.language = c.language.to_owned();
let mut run = RunState::new(
repo.to_path_buf(),
"main".to_owned(),
"0000000".to_owned(),
c.request.to_owned(),
config,
);
run.id = id.to_owned();
run.created_at = ago(24);
run.status = RunStatus::Judging;
run.candidates = vec![
candidate(
'A',
c.summaries[0],
" src/uploader.rs | 19 +++++++++++--\n 1 file changed",
1,
214,
),
candidate(
'B',
c.summaries[1],
" src/backoff.rs | 21 +++++++\n src/uploader.rs | 38 +++++++++-\n tests/retry.rs | 17 +++++\n 3 files changed",
3,
268,
),
candidate(
'C',
c.summaries[2],
" src/middleware.rs | 64 ++++++++++++\n src/uploader.rs | 12 ++-\n 2 files changed",
2,
301,
),
];
run.event("implement", c.event_implement);
if stage == "inflight" {
return run;
}
run.judgements = vec![
judgement(1, ['B', 'C', 'A'], c.reasons[0]),
judgement(2, ['B', 'A', 'C'], c.reasons[1]),
judgement(3, ['B', 'C', 'A'], c.reasons[2]),
];
run.tally = Some(Tally {
first_choice: BTreeMap::from([('A', 0), ('B', 3), ('C', 0)]),
borda: BTreeMap::from([('A', 1), ('B', 6), ('C', 2)]),
winner: 'B',
rankings: 3,
unanimous_initial: true,
deliberated: false,
changed_votes: 0,
unanimous_final: true,
tie_break: None,
judges: 3,
present: 3,
quorum: 2,
met_quorum: true,
uncontested: None,
});
let f1 = finding(
"R1-1-1",
Severity::Major,
"src/uploader.rs",
27,
c.finding_title,
c.finding_detail,
);
run.reviews = vec![
ReviewRound {
round: 1,
head: "b3c91e0".to_owned(),
verified_head: Some("b3c91e0".to_owned()),
verified_at: Some(ago(9)),
reviews: vec![
review(
1,
c.review_backoff,
vec![f1],
ReviewVote::ApproveWithFindings,
),
review(2, c.review_reads_well, Vec::new(), ReviewVote::Approve),
],
e2e: Vec::new(),
verify_retried: false,
e2e_deferred: false,
e2e_defer_reason: None,
fix: Some(magi::run::FixRecord {
agent: String::new(),
addressed: vec!["R1-1-1".to_owned()],
rejected: Vec::new(),
notes: c.fix_notes.to_owned(),
committed: true,
failed: None,
duration_ms: 63_000,
continuation: None,
}),
blocking: 1,
answered: 2,
expected: 2,
clean: false,
progressed: true,
vote_split: false,
reconsideration: Vec::new(),
verdict: Some(ReviewVote::ApproveWithFindings),
},
ReviewRound {
round: 2,
head: "e07a4d2".to_owned(),
verified_head: Some("e07a4d2".to_owned()),
verified_at: Some(ago(4)),
reviews: vec![
review(1, c.review_fixed, Vec::new(), ReviewVote::Approve),
review(2, c.review_nothing, Vec::new(), ReviewVote::Approve),
],
e2e: Vec::new(),
verify_retried: false,
e2e_deferred: false,
e2e_defer_reason: None,
fix: None,
blocking: 0,
answered: 2,
expected: 2,
clean: true,
progressed: false,
vote_split: false,
reconsideration: Vec::new(),
verdict: Some(ReviewVote::Approve),
},
];
run.status = RunStatus::Reviewing;
run.event("review", c.event_review);
if matches!(stage, "reviewed" | "question") {
return run;
}
run.status = RunStatus::Landing;
run.pr = Some(PrRecord {
url: PR_URL.to_owned(),
number: PR_NUMBER,
state: "open".to_owned(),
checks: "green".to_owned(),
round: 0,
rounds: 3,
red_at_merge: Vec::new(),
});
run.event("land", fill(c.event_opened, c));
if stage == "approval" {
return run;
}
run.status = RunStatus::Merged;
if let Some(pr) = run.pr.as_mut() {
pr.state = "merged".to_owned();
}
run.merge = Some(MergeOutcome {
mode: magi::config::MergeMode::Pr,
ok: true,
detail: PR_URL.to_owned(),
empty: false,
});
run.event("land", fill(c.event_merged, c));
run
}
fn main() -> Result<()> {
const USAGE: &str = "usage: demo_seed <stage> <repo> [en|ja] | demo_seed strings [en|ja]";
let mut args = std::env::args().skip(1);
let stage = args.next().context(USAGE)?;
if stage == "strings" {
let c = content(&args.next().unwrap_or_else(|| "en".to_owned()))?;
println!(
"{}",
serde_json::json!({
"request": c.request,
"choice": c.question_choices[0],
"question": c.question_summary,
})
);
return Ok(());
}
let repo = PathBuf::from(args.next().context(USAGE)?);
let c = content(&args.next().unwrap_or_else(|| "en".to_owned()))?;
if !["inflight", "reviewed", "question", "approval", "merged"].contains(&stage.as_str()) {
bail!("unknown stage {stage:?}");
}
let Some(home) = std::env::var_os("MAGI_HOME").map(PathBuf::from) else {
bail!("MAGI_HOME is not set; refusing to use the default home");
};
std::fs::create_dir_all(&home)?;
let id_file = home.join("demo-run-id");
let id = match std::fs::read_to_string(&id_file) {
Ok(s) => s.trim().to_owned(),
Err(_) => {
let id = format!("{}-d3a0", ago(24).strftime("%Y%m%d-%H%M%S"));
std::fs::write(&id_file, &id)?;
id
}
};
let mut run = build(&id, &repo, &stage, c);
let queue = Queue::at(home.join("queue"));
let mut task = queue
.list()
.into_iter()
.find(|t| t.instruction.contains(c.request))
.unwrap_or_else(|| {
Task::new(
c.request.to_owned(),
c.request.to_owned(),
repo.clone(),
Source::Human,
)
});
task.runs = vec![id.clone()];
task.attempts = 1;
task.status = if stage == "merged" {
TaskStatus::Done
} else {
TaskStatus::Running
};
queue.put(&mut task)?;
run.origin = Some(Origin {
by: StartedBy::Queue {
task: task.id.clone(),
},
task: Some(task.id.clone()),
});
let questions = Questions::at(home.join("questions"));
if matches!(stage.as_str(), "question" | "approval" | "merged") {
let have = questions
.list()
.into_iter()
.any(|q| q.node == "implement" && q.run == id);
if !have {
let mut q = magi::ask::Question::new(
id.clone(),
"implement".to_owned(),
"impl-B".to_owned(),
c.question_summary.to_owned(),
c.question_detail.to_owned(),
c.question_choices.iter().map(|s| (*s).to_owned()).collect(),
);
q.asked_at = ago(6);
questions.put(&mut q)?;
}
}
if matches!(stage.as_str(), "approval" | "merged") {
let have = questions
.list()
.into_iter()
.find(|q| q.node == land::APPROVAL_NODE && q.run == id);
if let Some(q) = have {
run.land_approval = Some(LandApproval {
question: q.id,
head: "e07a4d2".to_owned(),
});
} else {
let pr = PrState {
url: PR_URL.to_owned(),
number: PR_NUMBER,
state: PrLifecycle::Open,
checks: Checks::Green,
failing: Vec::new(),
review_comments: Vec::new(),
blocking: Blocking::No,
};
let commits: Vec<String> = c.commits.iter().map(|s| (*s).to_owned()).collect();
let html = land::approval_panel(&run, &pr, NUMSTAT, DIFF, &commits, c.subject);
let mut q = magi::ask::Question::new(
id.clone(),
land::APPROVAL_NODE.to_owned(),
"land".to_owned(),
fill(c.approval_summary, c),
fill(c.approval_detail, c),
vec![land::APPROVE.to_owned(), land::HOLD.to_owned()],
);
questions.put_panel(&mut q, &html, &[])?;
q.asked_at = ago(1);
questions.put(&mut q)?;
run.land_approval = Some(LandApproval {
question: q.id.clone(),
head: "e07a4d2".to_owned(),
});
}
}
run.save_under(&home)?;
println!("{id}");
Ok(())
}