Skip to content

verify merge result fails when the gate is stale against the merge result, so carry-through cannot complete a run - #154

Merged
djabi merged 2 commits into
mainfrom
flow/issue-153
Sep 3, 2026
Merged

verify merge result fails when the gate is stale against the merge result, so carry-through cannot complete a run#154
djabi merged 2 commits into
mainfrom
flow/issue-153

Conversation

@djabi

@djabi djabi commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Plan

Plan: Fix stale gate binary in verify-merge (issue #153)

Context

Carry-through resolutions fail unconditionally when main has taken any
commit touching tools/build/ since the arena last built. stepVerifyMerge
materialises the merge result via PrepareMergeResult (a local merge of
origin/main into the worktree), then runs bin/gate. If that merge
brought newer tool source, the compiled gate is stale against the tree it is
asked to measure. The gate's staleness check (tools/build/common/stale.go:StaleReason)
exits without printing an envelope; the runner classifies that as
OutcomeDied; runIntegrationGate returns a plain error; the step fails
and the resolution stops.

Nothing is misreported: the runner correctly says "died" and the step
correctly says "this is not the change failing." The fault is upstream: a
stale binary was asked to measure a tree it cannot, and nothing rebuilt it
first. Separately, the non-measured outcome and a verdict refusal both land
as a plain step failure, discarding a distinction the runner already made.

Normative check

Consistent. docs/backend.md (Optional capabilities table, line 115)
documents MergeResultPreparer as an optional worktree capability; the new
ToolsRebuilder follows the same pattern. docs/gates-and-commands.md
(lines 26-41) requires that the gate measures the merge result, and
docs/resolution-standalone.md (lines 113-124) requires that carry-through
still verifies against what will actually land. No normative document speaks
to tool rebuilding or to the transient classification of non-measured
outcomes; the documents are silent on both, and nothing in them contradicts
either change. The plan includes a doc update to docs/backend.md adding
ToolsRebuilder to the optional capabilities table.


Changes

1. Add ToolsRebuilder optional interface

File: backend.go (after MergeResultPreparer, ~line 794)

// ToolsRebuilder is an optional Worktree capability: rebuild the project's
// dev tools so they match the current tree.  Needed after
// PrepareMergeResult changes the tree -- compiled tools may be stale when
// the merge brings newer tool source from the base branch.
type ToolsRebuilder interface {
    RebuildTools(ctx context.Context) error
}

2. Implement on github worktree

File: pkg/backend/github/worktree.go (after RevertMergePrep, ~line 164)

// RebuildTools implements flow.ToolsRebuilder: run ./make in the worktree
// to rebuild dev tools against the current tree.  The meta-builder runs via
// 'go run' and is never stale itself; it short-circuits when tools are
// already up to date.
func (w *worktree) RebuildTools(ctx context.Context) error {
    return w.run(ctx, "rebuild tools", []string{"./make"})
}

Uses the existing w.run() method (line 267), which executes a command in
cfg.WorktreeDir via exec.Command. ./make is at the repo root
(= cfg.WorktreeDir), has a shebang, and short-circuits if up-to-date.
bin/ is gitignored, so the rebuild does not affect StatusPorcelain checks.

3. Rebuild tools in stepVerifyMerge after merge-prep

File: issue/steps_integration.go (inside the if prep, ok block,
after PrepareMergeResult succeeds, before runIntegrationGate)

Insert after the defer block (~line 47), before line 50:

// The merge brought main's tree into the worktree -- tool source may
// have changed, making compiled binaries stale.  Rebuild before
// running the gate so the staleness check does not refuse to measure.
if rb, ok := wt.(flow.ToolsRebuilder); ok {
    if err := rb.RebuildTools(ctx.Context()); err != nil {
        return fmt.Errorf("could not rebuild tools against the merge result: %w", err)
    }
}

The rebuild is inside the merge-prep guard: it runs only when the tree was
actually changed by a merge simulation. A worktree that does not implement
MergeResultPreparer will not reach it. A rebuild failure is a plain error
(not ErrTransient) because it signals a broken build environment, not a
transient condition.

4. Non-measured gate outcomes wrap ErrTransient

File: issue/steps.go, function runIntegrationGate (lines 721-746)

Three branches currently return plain errors for infrastructure failures.
Wrap each with flow.ErrTransient so the orchestrator parks without burning
budget:

a) RunGate errored (line 724-728): no gate ran at all.

if err != nil {
    return flow.GateVerdict{}, fmt.Errorf(
        "no %s gate ran on %s, so nothing was measured -- this is not the "+
            "change failing: %v: %w", flow.GateIntegration, subject, err, flow.ErrTransient)
}

b) Non-measured outcome (line 729-733): died, timed out, etc.

if run.Outcome != flow.OutcomeMeasured {
    return flow.GateVerdict{}, fmt.Errorf(
        "the %s gate reports %q on %s, so nothing was measured -- this is not "+
            "the change failing%s: %w", flow.GateIntegration, run.Outcome, subject,
            detailSuffix(run.Detail), flow.ErrTransient)
}

c) Judge errored (line 734-738): no verdict exists.

if err != nil {
    return flow.GateVerdict{}, fmt.Errorf(
        "the %s gate measured %s but no verdict exists, which is not a refusal -- "+
            "the project's judging layer could not answer: %v: %w",
            flow.GateIntegration, subject, err, flow.ErrTransient)
}

NOT changed: the verdict-refusal branch (line 740-744). A refusal is
the gate's answer about the change -- deterministic, not transient.

Both call sites of runIntegrationGate benefit: stepVerifyMerge
(line 50) and the contributor-mode gate in stepOpenPR (line 630).

5. Update normative doc

File: docs/backend.md (line 116, optional capabilities table)

Add a row after MergeResultPreparer:

| `ToolsRebuilder` | Rebuild dev tools so they match the current tree. Needed after `MergeResultPreparer` changes the tree. |

6. Tests

File: issue/steps_integration_test.go

a) Extend integrationWorktree with ToolsRebuilder:

  • Add fields toolsRebuilt bool, rebuildToolsErr error
  • Add method RebuildTools that records the call, sets toolsRebuilt, returns rebuildToolsErr

b) Update TestStepVerifyMerge_GatePassesMergeResultAccepted (line 125):

  • Assert wt.toolsRebuilt == true
  • Assert call order: merge-prep before rebuild-tools before gate:integration

c) Update TestStepVerifyMerge_GateNotMeasured (line 190):

  • Assert errors.Is(err, flow.ErrTransient)

d) Update TestStepVerifyMerge_GateError (line 176):

  • Assert errors.Is(err, flow.ErrTransient)

e) Update TestStepVerifyMerge_JudgeError (line 206):

  • Assert errors.Is(err, flow.ErrTransient)

f) Add TestStepVerifyMerge_RebuildToolsFails:

  • Set rebuildToolsErr, verify step returns error, verify !errors.Is(err, flow.ErrTransient) (plain error, not transient)

g) Add TestStepVerifyMerge_RebuildToolsCallOrder:

  • Verify calls slice has merge-prep then rebuild-tools then gate:integration

h) Check whether TestStepVerifyMerge_WithoutMergeResultPreparer (line 248)
needs an assertion that rebuild-tools was NOT called (it should already
be skipped since the rebuild is inside the MergeResultPreparer guard).

File: issue/steps_test.go (contributor-mode gate tests)

i) Any tests asserting on runIntegrationGate errors for non-measured outcomes
or gate/judge errors need errors.Is(err, flow.ErrTransient) assertions added.
Search for uses of gateOutcome and gateErr in stepOpenPR tests.


Deliberately not done

  • No rebuild in the contributor-mode gate (stepOpenPR, line 630).
    That step measures the branch as-is, not a merge result. The tree has not
    changed from under the gate, so staleness there is a different cause with a
    different fix.

  • No ErrRefused for OutcomeCouldNotStart. All non-measured outcomes
    share the same transient treatment. Refining them into separate sentinels
    is future work if needed, not part of this fix.

  • No rebuild-revert in the defer. After RevertMergePrep resets the
    tree, the rebuilt tools are stale against the reverted tree. This is
    harmless: the next steps (stepMerge, stepRecordMerge) do not run the
    gate, and the claim ends after them.

Verification

  1. go build ./... -- compiles.
  2. go test ./issue/... -run TestStepVerifyMerge -- all verify-merge tests pass,
    including the new ones.
  3. go test ./pkg/backend/github/... -run TestGate -- existing gate runner tests
    unaffected.
  4. go test ./... -- full suite green.
  5. bin/verify -- the project's own gate passes.

Review

bin/verify passes clean. Here is the review.


Review

The change does both things the issue asks for, in the right order:

  1. Rebuild tools against the merge result (ToolsRebuilder interface + RebuildTools call in stepVerifyMerge, after PrepareMergeResult and before the gate). This is the primary fix — stale binaries are rebuilt before they can refuse to measure.

  2. Classify infrastructure failures as transient (ErrTransient wrapping in runIntegrationGate for all three non-code-failure error paths). This is the fallback — if for any reason the gate still can't run, the orchestrator parks transient instead of burning invocation budget and reporting a failed step.

Correctness

  • The rebuild is correctly gated inside the MergeResultPreparer block — it only runs when the tree was actually changed by a merge simulation. Without merge prep there's no staleness.
  • Call ordering is verified by test: merge-prep → rebuild-tools → gate:integration.
  • The three ErrTransient wrappings target exactly the right cases: (a) gate couldn't start, (b) gate ran but didn't measure (died), (c) judge couldn't answer. The actual-failure path (verdict not acceptable) is left as a hard error — correctly, since that is the change failing.
  • A RebuildTools failure is not transient (test at steps_integration_test.go:320), which is correct: a broken build isn't going to fix itself on retry.
  • ./make is the project's standard build trampoline; hardcoding it here matches every other reference in tools/build/.

Scope

Every file in the diff is load-bearing for the issue. No unrelated changes.

Duplication

No existing rebuild mechanism was available on Worktree; the new interface is the first one.

Tests

  • Happy path verifies merge-prep, rebuild, gate ordering, and revert (TestStepVerifyMerge_GatePassesMergeResultAccepted).
  • Gate-error, not-measured, and judge-error paths all assert ErrTransient wrapping.
  • Rebuild failure asserts hard (non-transient) error.
  • WithoutMergeResultPreparer asserts neither merge-prep nor rebuild-tools fires on a worktree that doesn't support them.
  • The stepOpenPR tests that already exercised these same runIntegrationGate error paths now additionally assert ErrTransient. These tests would fail if the wrapping were reverted.

Normative documents

docs/step-handler.md defines ErrTransient as "infrastructure failures the handler observed" — a gate that can't start or didn't measure is exactly that. The one potential tension is ErrRefused's description ("a required tool is out of date"), but the issue's own analysis distinguishes: ErrRefused is deterministic and would strand the item, whereas stale tools are transient because a rebuild between runs clears them.

docs/backend.md is updated with the new ToolsRebuilder capability row, consistent with the existing table format.

Nothing found to fix.

Coverage

bin/verify passes clean.


Already covered by the existing tests in the diff:

  • All three infrastructure error paths in runIntegrationGate wrap ErrTransient (gate error, non-measured outcome, judge error) — tested from both stepVerifyMerge and stepOpenPR callers.
  • Happy path: RebuildTools called after merge prep, before the gate, in the correct order.
  • RebuildTools failure stops the step with a non-transient error.
  • RebuildTools not called when the worktree is not a MergeResultPreparer.
  • RevertMergePrep called even when the gate fails.

Added:

  1. TestStepVerifyMerge_GateRefuses (issue/steps_integration_test.go): asserts that a gate refusal does NOT wrap ErrTransient. This is the central invariant of the change — infrastructure failures are transient, real verdicts are not. Without this assertion, a regression that made refusals transient would pass all tests.

  2. TestStepOpenPR_DoesNotProposeWhatTheJudgeRefuses (issue/steps_test.go): same not-transient assertion for the open-PR caller of runIntegrationGate.

  3. TestStepVerifyMerge_RebuildToolsFails (expanded, issue/steps_integration_test.go): two new assertions on the failure path — (a) the gate must not have been called (short-circuit), and (b) RevertMergePrep must still be called (the defer was set before rebuild ran). The original test only checked that the step errored and wasn't transient.

Not added, and why:

  • No unit test for worktree.RebuildTools in the GitHub backend — it delegates to w.run(ctx, "rebuild tools", []string{"./make"}), which is the same run helper every other worktree method uses. Testing it would restate the implementation.
  • No assertion that TestStepVerifyMerge_MergeConflicts is not transient — that path was not changed by this diff, and merge conflicts are a genuinely ambiguous category (they can resolve after main changes).

Gate

  • gate: integration
  • outcome: measured
  • acceptable: true
  • verdict: every judged metric is within its cap

measurement:

{"gate":"integration","metrics":[{"name":"unformatted_files","type":"int","value":0},{"name":"unbuildable_packages","type":"int","value":0},{"name":"vet_findings","type":"int","value":0},{"name":"failed_tests","type":"int","value":0},{"name":"failed_packages","type":"int","value":0}]}

thresholds:

{"failed_packages":0,"failed_tests":0,"unbuildable_packages":0,"unformatted_files":0,"vet_findings":0}

Closes #153

…result, so carry-through cannot complete a run

Closes #153
…the merge result, so carry-through cannot complete a run
@djabi
djabi merged commit 8a76184 into main Sep 3, 2026
2 checks passed
@djabi
djabi deleted the flow/issue-153 branch September 3, 2026 04:30
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 3, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

verify merge result fails when the gate is stale against the merge result, so carry-through cannot complete a run

1 participant