fix(gate): a run that decides nothing records why, and still burns no cycle - #253
Conversation
… cycle The reported symptom was a `gate gate` run killed mid-evidence by a reset connection to api.github.com — exit 4, no verdict, no escalation, but an evidence artifact already in the log. The diagnosis was that cycle counting derives from runs owning evidence, so the blip spent one of the PR's three review cycles. Counting has never read evidence. cycleCount joins outcome -> parent reduced verdict -> subject, and only distinct runs holding a counting action or escalation count; the aborted run owns neither, so it contributed zero. What this change fixes is everything around that invariant. The invariant is now asserted rather than incidental. One subject walks the three cases in order — a completed run consumes a cycle, a run that dies gathering evidence consumes none, the next completed run consumes the next — and `gate next`'s number is checked against the one gate enforces, so the two counting rules cannot drift apart unnoticed. An aborted run now says it aborted. It appends a `run_aborted` record naming the subject and the cause, outside the action/escalation families like `grant_needed`, so the count, the reducer, and `next`'s subject reduction all ignore it. The evidence it had already recorded stays: an append-only log answers "this run died" by appending the fact, never by un-writing what came before. Without the record, `explain` showed a run that simply stopped. And the blip is now unlikely to end a run at all. Each `gh` read retries up to three times with growing backoff on transport faults and GitHub's retryable statuses. The classifier is an allowlist — a missing binary, a bad credential, a 404, a malformed query fail on the first attempt instead of sleeping through the bound — and it fails closed: a failure that outlives the bound is returned unchanged and the run still aborts. Retrying is safe only because every one of these calls is a read, and it must never become a way to proceed without an answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@claude review |
|
@codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Claude finished @itsHabib's task in 3m 9s —— View job Code Review — PR #253Todos
Reviewed head:
|
|
Reviewer: claude |
There was a problem hiding this comment.
🔵 Needs a closer look
It changes core gate decision-path behavior (new durable artifact type plus evidence-retry semantics) that affects authorization invariants and should receive final human review.
Pull request overview
This PR hardens gate’s review-cycle accounting and run observability by (1) explicitly recording when a run aborts before any decision is produced, (2) pinning the “only decisions burn cycles” invariant with tests, and (3) making evidence collection more resilient via bounded, allowlisted retries for transient gh failures.
Changes:
- Add a
run_abortedartifact recorded on undecided (exit-4) runs so the log can explain why a run stopped without producing an outcome, while still burning no review cycle. - Add regression tests that prove aborted runs do not advance cycle count and that
gate next’s budget display matches the enforced cycle counting rule. - Retry
ghevidence reads up to 3 times with growing backoff for allowlisted transient failures; fail closed at the bound and for permanent errors.
File summaries
| File | Description |
|---|---|
| cmd/gate/main.go | Records run_aborted on undecided error exits; cycle counting remains outcome-based. |
| cmd/gate/internal/state/state.go | Adds KindRunAborted and its ID prefix. |
| cmd/gate/internal/observe/observe.go | Projects/renders run_aborted as a flat node in explain output. |
| cmd/gate/internal/evidence/evidence.go | Implements bounded retry around gh reads with transient-failure allowlist. |
| cmd/gate/internal/evidence/panel_test.go | Extends package TestMain to dispatch the new retry helper process. |
| cmd/gate/internal/evidence/retry_test.go | New tests pin retry/backoff behavior and fail-closed semantics. |
| cmd/gate/aborted_cycle_test.go | New tests pin cycle invariants across completed/aborted/completed runs and next agreement. |
| cmd/gate/docs/DESIGN.md | Documents the outcome-based cycle rule, abort annotation, and retry policy. |
| cmd/gate/CLAUDE.md | Updates tenant guidance to include the new invariants and retry behavior. |
| cmd/gate/AGENTS.md | Mirrors the same guidance updates as CLAUDE.md. |
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const ghResetAfterView = `#!/bin/sh | ||
| # echo, not cat: PATH holds only this stub, so no external binary is reachable. | ||
| if [ "$1 $2" = "pr view" ]; then | ||
| echo '{"state":"OPEN","isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","baseRefName":"main","reviewDecision":"APPROVED","statusCheckRollup":[],"headRefOid":"abc123","title":"t","mergedAt":null,"author":{"login":"someone","is_bot":false},"mergeCommit":null}' | ||
| exit 0 | ||
| fi | ||
| echo 'error connecting to api.github.com: Post "https://api.github.com/graphql": read tcp 10.0.0.2:53000->140.82.113.5:443: read: connection reset by peer' >&2 | ||
| exit 1 | ||
| ` |
…path Round 1 against the panel on a7a14a3. Copilot (aborted_cycle_test.go): the /bin/sh stub tied the new invariant test to a POSIX shell, while the evidence package already stubs gh portably by installing the test binary on PATH and intercepting in TestMain. Switched to that pattern — which also removes the shell-quoting fragility the script had (PATH holds only the stub, so even `cat` was out of reach). Also pins a cross-feature edge the retry allowlist has to respect: an oversized diff (HTTP 406) routes to the local-diff fallback, so it must not be retried — the answer is deterministic and retrying would only delay it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 1 dispositions against the panel on Copilot — Claude — no blocking issues; three observations, all acknowledged rather than changed:
Codex — out of quota (why #250 dropped it from Also folded in one pin the review prompted rather than requested: an oversized diff ( @claude review |
|
Claude finished @itsHabib's task in 2m 12s —— View job Code Review — PR #253 (Round 2, head
|
|
Gate: parked —
Not a finding against the diff. The panel requires a formal GitHub review from This is the same terminal state #200 and #242 reached — the judge's residual acceptance is the terminating condition, not a fourth panel round. Not re-running gate: a second run would re-evaluate from scratch and re-park on the same gap. Awaiting the operator-gated judge. |
Resolves the two docs conflicts and one semantic conflict that the textual merge did not surface. cmd/gate/AGENTS.md and cmd/gate/CLAUDE.md conflicted as append-vs-append on the same bullet list: this branch documents inbox closure by supersession and mootness, main's #253 documents cycle accounting from outcomes and the evidence-read retry allowlist. The bullets are independent, so both sides are kept. cmd/gate/aborted_cycle_test.go merged cleanly but did not compile: main added it calling observe.NextText with the old trailing stateArg string, while this branch replaced that parameter with observe.NextRequest. Adapted the call to observe.NextRequest{}, matching how this branch's sibling cycles_preflight_test.go already spells it — same empty StateArg, so the test's meaning is unchanged. go build ./... , go vet ./cmd/gate/... and go test ./cmd/gate/... all pass on the merged tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The report, and what the log actually says
The failure was real.
run_f7ea75f642e3064c(#247, 2026-08-23)died with exit 4 on
Post "https://api.github.com/graphql": read tcp …: connection reset by peer, aftergh pr viewhad already landed its evidenceartifact.
The counting claim is not.
cycleCounthas joined outcome → parent reducedverdict → subject since gate moved in at #59 — one distinct run holding a
counting action or escalation is one cycle. Evidence is never read. Recomputing
both counts over the live log for #247:
The aborted run owns exactly one artifact — the
pr viewevidence — and nooutcome, so it contributed zero. The single cycle came from the completed run
that followed it and parked for judgment. Option (c) in the issue is the
shipped design, so the literal fix is a no-op.
That leaves three things worth doing, and this PR does all three.
Weighing the options
rejected. It would delete the honest record of what gate read, and the
already-merged refusal is decided from the view artifact specifically so it
lands before the rest of the sweep can fail. Withholding evidence to protect a
count that doesn't read evidence trades a real property for an imaginary one.
fix the count, but because the log cannot currently say a run died:
gate explain -run run_f7ea75f642e3064cprints one evidence artifact, exit 0,and nothing else. An aborted run is indistinguishable from one still in flight.
tests instead of holding by construction.
What changed
The invariant is asserted, not incidental.
TestOnlyRunsThatDecideBurnACyclewalks one subject through all three cases in order — a completed run consumes a
cycle, a run that dies gathering evidence consumes none, the next completed run
consumes the next — so a regression in either direction fails. The abort is
driven by a stub
ghthat reproduces the real shape: view succeeds, diff dies onthe reset. A second assertion checks
gate next's number against the one gateenforces; the two counting rules live in different packages
(
main.cycleCountandobserve.cyclesBySubject) and nothing previously stoppedthem drifting apart — an operator reading "2/3" while gate refuses at 3.
An aborted run now says so. A
run_abortedrecord naming the subject and thecause, appended at the single error funnel in
runGateWithSynthesis. It sitsoutside the action/escalation families exactly like
grant_needed, so the count,the reducer, and
next's subject reduction all ignore it — that exclusion ispinned too. The evidence already written stays: an append-only log answers "this
run died" by appending the fact, never by un-writing what came before. Two exits
are deliberately not annotated — any code but
codeErrorreached a terminaland decided something, and a tampered log is corruption rather than an abort
worth describing.
The blip is now unlikely to end a run at all. Each
ghread retries up tothree times with growing backoff. The classifier is an allowlist — transport
faults and GitHub's retryable statuses retry; a missing binary, a bad credential,
a 404, a malformed query fail on the first attempt rather than sleeping through
the bound. It fails closed: a failure that outlives the bound is returned
unchanged and the run still aborts. Retrying is safe only because every one of
these calls is a read, and it must never become a way to proceed without an
answer —
TestGHFailsClosedAtTheBoundis the guard on that.Consistency with #242 and #249
Rebased onto
main(8fc0300). #242 landed the pre-flight ceiling refusal andcycles_used/cycles_maxon every result — both are on main, and this PRchanges neither the counting rule nor the reporting fields, so #242's
grant_neededrefusal record and thisrun_abortedrecord are siblings underthe same law: durable facts, never outcomes. #249 (decision provenance) is open
and touches the decision path, not counting; no overlap.
Checks
gofmt·go vet·golangci-lint run ./...(0 issues) ·go test ./...·go test -race ./cmd/gate/...— all green. Guide pair re-synced; boundary lawverified locally (no new cross-tool imports).
🤖 Generated with Claude Code