Skip to main content

SCHEMA

Constant SCHEMA 

Source
pub const SCHEMA: u32 = 10;
Expand description

On-disk format version. Bumped when a field changes meaning, so a resumed run never half-reads a state file written by a different magi.

2: added RunStatus::Stalled, RunState::quota (rate-limit losses), and the quorum fields on Tally. RunState::load already fails loudly and clearly on a schema mismatch; an old run.json from schema 1 now says so instead of silently half-reading.

3: added RunState::judge_skipped. A solo candidate makes judge write only an event, leaving judgements empty forever — indistinguishable from “not yet judged” on every later reentry, which is what let judge re-run on a finished run and clobber its status back to Judging. The flag is the missing record of the fact that judging was skipped on purpose.

Also 3: a single-viable-candidate tally records Tally::judges as 0 and fills Tally::uncontested, instead of leaving the full roster size sitting next to a panel that never sat. A schema-2 record keeps reading as “0 of 3 judges present” forever, because a tally is computed once and never recomputed on resume; the bump keeps that stale reading from being mixed with the new meaning.

4: added ReviewRound::progressed. graph::STAGNANT_LIMIT counts consecutive rounds with progressed == false to decide whether the review loop should give up early, and a schema-3 record’s default false would misreport a round that, at the time, actually committed a real diff — the field simply did not exist yet to say so. Without the bump, resuming an old multi-round review could spuriously trip the stagnation check on rounds that were never stagnant.

5: added RunStatus::Landing. A run inside crate::land’s post-merge loop used to carry whatever status merge set before calling it forward unchanged - Merged, even while still watching CI or waiting on the owner’s approval - which is also the one status RunStatus::resumable treats as finished. A daemon that gave this run’s slot back to poll something else while an approval was outstanding, or one that simply crashed mid-land, had no way to tell “still landing” from “actually merged” and would either restart the whole competition or leave the run stuck reading as done. A schema-4 record has no notion of Landing at all, so this is a meaning a resumed old run cannot be guessed into rather than a value it can default to - hence the bump, not a #[serde(default)].

6: a deferred e2e is represented by an empty outcome list plus ReviewRound::e2e_deferred. Schema 5 treated that same empty list as an unconfigured, successful check, so schema-5 records are migrated with the old (not-deferred) meaning while older binaries reject schema-6 records.

7: added RunState::gate_ran. An empty RunState::gate used to carry two meanings at once — “never attempted, or the last attempt was resource-blocked” (graph::Runner::gate’s retry case) and “attempted, zero commands configured, vacuously passed” (a repo with no verify.gate) — and nothing told them apart. graph::Runner::merge therefore read the second case as the first and refused forever: a review-only run with no gate commands configured reached Gating and then could never leave it. A schema-6 record’s non-empty gate is migrated to gate_ran = true (a recorded attempt, real or historical, should not be spent again); an empty one migrates to gate_ran = false and is simply re-attempted by the next gate() call, which self-heals instantly for the zero-commands case.

8: ReviewRound::verified_head used to be None for the overwhelming majority of rounds — every ordinary round that ran e2e against its own head in the main review loop never set it at all, leaving only the rare catch-up-on-a-different-commit case populated. A reader (a review prompt, magi show, the web UI) had no field to ask “which commit did this round’s e2e actually check” and fell back to assuming it was always head, which is also what let a stale round’s red output get quoted to a later round’s reviewers as if it were about their patch, not an earlier one (see ReviewRound::verification_summary, which now exists so nowhere else has to guess). verified_head is now set whenever e2e held a real attempt (E2eStatus::Passed/Failed), always naming the commit actually checked instead of only the divergent case, and ReviewRound::verified_at is new alongside it. A schema-7 round’s own unconditional main-loop check was always against head whether or not this field said so, so a None with a non-empty e2e migrates to Some(head) — a reconstruction of a fact that was always true, not a guess. verified_at has no historical value to reconstruct and stays None, which reads through verification_summary as “checked at: unknown” — an honest gap, not a fabricated time. 9: added RunState::operator_fixes — one record per magi fix invocation, routing specific, already-recorded findings to a fixer as a targeted, out-of-band fix outside the normal round sequence. Kept in a channel of its own rather than folded into ReviewRound, because a reviewer’s own severity and vote (copied verbatim onto OperatorFixFinding) must never be rewritten to look like the operator manufactured a blocking verdict — see graph::Runner::fix_selected. A schema-8 record has no operator-fix history at all, and #[serde(default)] reads an empty list as exactly that: “none happened”, not an unknown gap. Nothing about an existing field’s meaning changes.

10: added RunStatus::VerifiedNoop and Candidate::verified_noop. Before this, an implementer that correctly concluded (with evidence) that a task’s request was already satisfied elsewhere had no way to say so: the run ended the same way as one where every candidate simply failed to write anything — after_implement bailing with “no candidate produced a change; nothing to judge” and the run settling as a plain Failed. That conflated two very different facts (investigation run 391f’s audit is what surfaced it: two attempts that had, correctly, found their fix already on main). A schema-9 record has no notion of either the new status or field, so a VerifiedNoop value is a meaning that cannot be reconstructed from an old record — hence the bump, not a #[serde(default)] for the status. Candidate::verified_noop alone does default-read as None on an old record, which is the honest reading: a run written before this schema never made the claim.

The report task 391f itself was raised from also named 6c5e, 8df3 and e9ce as three more tasks whose implement wave ended the same diff-zero way, and the investigation traced all three — they do not share one cause.

6c5e and 8df3 are the same already-landed pattern as 391f, not a coincidence: all three were re-queued together by a same-day audit of done-but-unlanded magi tasks (queue talk 20260912-115153-7216, 2026-09-12 02:51–04:24), which found 17 magi tasks marked done with no merge to show for it and re-queued 16 of them, 6c5e (a fix for the owner’s magi ask --thread back-and-forth) and 8df3 (release automation) included. A second, same-day audit (talk 20260912-222053-07fe, 13:20–13:36) then found 12 of those re-queued tasks — 391f, 6c5e and 8df3 among them — already merged by another route, and the owner had them deleted (magi task rm); 391f alone survived because a daemon still held its run at the moment of deletion, which is the only reason any record of this group still exists to audit. Quoted directly from that second audit’s own turn (talk 07fe, so this reads without needing access to that talk store), naming both by id:

12件がマージ済み(対応不要)、3件が未実装(妥当)、2件が部分実装(要確認)でした。 マージ済み → hold/rmを推奨: 6c5e, 1ddc, fcf5, e25b, cea2, 391f, 3202, b0a1, 5365, af85, 9f26, 8df3

— followed by the owner answering “削除!” and the agent confirming “11件 削除完了。391f はいま実行中のdaemonが掴んでいて削除できませんでした.” git log independently confirms both fixes: the ask-back feature 6c5e wanted landed as f0df474 (“let the owner ask back on a question…”, #93) on 2026-09-06, and the release-bump automation 8df3 wanted landed as 61005dd/bedd925 (open a release-bump PR on merge) on 2026-09-07 and 116fcdc (proportional version bump, #108) on 2026-09-08 — all before the 09-12 requeue. No run record survives the deletion for either task, so this schema’s evidence is the audit transcript plus the independently re-checked git log, not a run.json.

e9ce is not that pattern at all, and is the reason the adoption guard below is all-or-nothing rather than “any candidate said so”: its task asked an implementer to merge the real repository’s main and cut a GitHub release — a destructive, out-of-worktree operation AGENTS.md names explicitly as not something to hand to an unattended candidate. Both of its runs (20260912-053352-49ad, 20260912-062629-bab1) correctly refused, filed magi ask (questions 6196, 6c9a), and ended with an empty diff only because no answer arrived before the implement node’s timeout — 49ad looped magi ask --wait in the foreground for roughly 50 minutes as instructed before the timeout cut it off; bab1 ended its turn moments after filing its question without ever actually blocking on the wait, a separate protocol slip this schema change does not attempt to fix. 49ad’s own candidates[0].summary (quoted here because both records predate schema 10 and, separately, predate a still-unrelated struct change that already makes today’s magi show refuse to parse either of them — unknown field 'planner' — so this is read straight from run.json on disk, not through that command):

タスクの内容(READY 状態の run を実リポジトリの main に merge --no-ff する、GitHub Release を作る)を精査した結果、これは全てこのワーカーの worktree の外にある実リポジトリと GitHub 上の共有状態に対する不可逆な 操作であり […] 私自身の運用ルール「Work only inside this worktree. Nothing outside it is yours.」と正面から矛盾すると判断しました。

Neither candidate’s reply carries the NO CHANGE NEEDED marker below, so both runs correctly stay Failed under this schema, not VerifiedNoop: a run blocked on an unanswered authorization question is not a verified no-op, and reading the two alike is exactly the misclassification the guard’s per-candidate and whole-run conditions exist to refuse.