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.