Fix #2245: rebaseline post-consensus-timeout budget on producer progress - #2253
Conversation
The hardcoded `post_timeout_budget = 3600` in `_run_concurrent_phase` counted iteration time against a single fixed bucket, so a healthy multi-iteration BRC consensus cycle (NACK → repropose → re-review) could be force-killed mid-iteration even when reviewers were actively producing useful feedback. Replace it with a per-iteration clock that rebaselines on producer progress: each fresh CONSENSUS_PROPOSE (initial or NACK→re-propose) resets the iteration budget so the next round of reviews gets a clean clock instead of inheriting the prior round's wall-clock spend. An absolute cap (`post_consensus_max_total_seconds`, default 4h) bounds the total wait so unbounded propose churn can't stall the pipeline. Both budgets are exposed on `PipelineConfig` for per-deployment tuning. Defaults (3600s per-iteration / 14400s absolute) preserve the prior force-kill point for the no-progress case.
There was a problem hiding this comment.
Review summary
Approve — fix is correct, scoped, and well-tested. The per-iteration rebaseline plus absolute cap is the right shape for #2245: it preserves the #1691 force-kill guarantee and the #1921 short-step polling, while letting a productive multi-iteration BRC cycle run to completion. I traced the data flow end-to-end (producer call to record_proposal updates _proposal_timestamps, get_latest_proposal_timestamp returns max() under the lock, post-timeout loop snapshots-then-compares each iteration) and walked the three test scenarios call-by-call against the mocked time.monotonic sequence — the assertions are sound.
A few non-blocking suggestions below; all of these can be follow-ups, none gate the merge.
Non-blocking
1. _latest_proposal_ts TypeError fallback is dead code
orchestrator/routes/pipelines.py:10696-10699:
try:
_t = _get_brc_tracker(_pid, _sid)
except TypeError:
_t = _get_brc_tracker(_pid)The current get_peer_consensus_tracker(pipeline_id, slice_id=None) signature (peer_consensus.py:1768) cannot raise TypeError for a positional slice_id. The docstring says it matches the _update_agents_complete pattern for older shims — but there are no older shims in tree. Worth dropping for clarity, or at least a # pragma: no cover so the dead branch isn't carried as a maintenance hazard.
2. getattr defaults on PipelineConfig are unnecessary
orchestrator/routes/pipelines.py:11252-11257:
post_timeout_iteration_budget = getattr(
pipeline.config, "post_consensus_iteration_budget_seconds", 3600
)
post_timeout_max_total = getattr(
pipeline.config, "post_consensus_max_total_seconds", 14400
)PipelineConfig is a Pydantic v2 BaseModel — when an older serialized config is rehydrated, missing fields take their declared defaults, not raise AttributeError. The getattr(...) defaults silently mask any future typo in field names. A direct pipeline.config.post_consensus_iteration_budget_seconds would catch a rename at runtime and is consistent with how consensus_timeout_minutes is read at line 10584 (which does use getattr, but for the same false-defensiveness reason).
3. No cross-field validation between the two knobs
orchestrator/models.py:380-403 — both fields are independently ge=60. A misconfigured pipeline with iteration_budget=7200, max_total=3600 silently makes the per-iteration logic unreachable: the absolute cap fires first every time. Consider a @model_validator(mode='after') that enforces post_consensus_max_total_seconds >= post_consensus_iteration_budget_seconds, or at minimum a warning log on the first read inside _run_concurrent_phase.
4. Test helper's __dict__ fallback bypasses Pydantic
orchestrator/tests/test_post_timeout_rebaseline.py:62-66:
for key, val in overrides.items():
try:
setattr(config, key, val)
except (AttributeError, ValueError):
config.__dict__[key] = valFor the defined fields under test (post_consensus_iteration_budget_seconds, post_consensus_max_total_seconds, concurrent_execution, etc.), setattr always succeeds on a Pydantic v2 model with validate_assignment=False (the default). The config.__dict__[key] = val fallback would silently not set the field through Pydantic's machinery if it ever fired — which is exactly the kind of test-only divergence from production that masks regressions. Either drop the fallback or use PipelineConfig(**overrides) directly via the constructor (which does validate).
5. Comment elides the actual safety guarantee
orchestrator/routes/pipelines.py:11262-11265:
# Snapshot the latest proposal timestamp at entry so we
# only count *new* proposals as progress signals. None
# is fine: any proposal arriving during the wait will
# compare strictly greater than None.datetime > None raises TypeError in Python 3 — the actual safety comes from the last_seen_proposal_ts is None or current_proposal_ts > last_seen_proposal_ts short-circuit at line 11334. Worth tightening the comment to reflect that, since the current wording would mislead a reader who hasn't read line 11334 yet.
6. Naming — iteration_budget is ambiguous
"Iteration" reads as either a BRC iteration (propose → review → re-propose) or a poll-loop iteration. The semantic is "wait time since last producer progress signal", which is closer to a quiet-period / progress-idle budget. Bikeshed: post_consensus_progress_idle_budget_seconds would be self-documenting. Not worth renaming a public config knob just for this — flagging in case the field is still in flight.
7. Pre-timeout (consensus_timeout_minutes) doesn't rebaseline
Out of scope here, just noting: a producer making productive progress before the 30-min consensus timeout fires gets no clock extension — only the post-timeout window benefits from #2245. The post-mortem on issue-1557-v2 was specifically about the post-timeout kill, so this scopes correctly to the observed failure mode, but if the same multi-iteration pattern can blow through 30 min pre-timeout, that's the next shoe to drop.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- Drop dead `TypeError` fallback in `_latest_proposal_ts`. The `get_peer_consensus_tracker(pipeline_id, slice_id=None)` signature cannot raise `TypeError` for a positional `slice_id`; the fallback was speculative and unreachable. - Read the new post-consensus knobs directly off `PipelineConfig` instead of `getattr(..., default)`. Pydantic provides defaults; the `getattr` defaults silently masked any future field rename. - Add a cross-field validator on `PipelineConfig` that rejects `post_consensus_max_total_seconds < post_consensus_iteration_budget_seconds`. Without it, a misconfigured pipeline silently makes the per-iteration rebaseline logic unreachable (the absolute cap fires first every time). - Replace the `__dict__` fallback in the test helper with `PipelineConfig(**overrides)` — the fallback was dead code and would have bypassed Pydantic validation if it ever fired. - Tighten the post-timeout-snapshot comment: the safety against `datetime > None` comes from the `is None` short-circuit at the rebaseline check, not from any datetime/None ordering. - Add tests for the new cross-field validator (rejects mismatched budgets, accepts equal budgets).
There was a problem hiding this comment.
Thanks for the careful review. Addressed in 91a7b4b:
TypeErrorfallback (dead code) — Agreed. Dropped the innertry/except TypeError;get_peer_consensus_tracker(pipeline_id, slice_id=None)can't raise it.getattrdefaults are unnecessary — Agreed. Now readspipeline.config.post_consensus_iteration_budget_seconds/...max_total_secondsdirectly. A future rename will surface asAttributeErrorinstead of being silently masked. (Left theconsensus_timeout_minutesgetattrat line 10584 alone — out of scope here.)- No cross-field validation between the two knobs — Agreed. Added a
@model_validator(mode='after')onPipelineConfigthat rejectspost_consensus_max_total_seconds < post_consensus_iteration_budget_seconds. New tests cover the mismatch + equal-budget boundary. - Test helper's
__dict__fallback bypasses Pydantic — Agreed. Replaced withPipelineConfig(**overrides)so all fields go through validation. The fallback was indeed dead code (novalidate_assignmenton this model). The validator from #3 caught one test that was constructingiteration_budget=5000, max_total=2500to demonstrate the absolute cap; rewrote it to use equal budgets — with always-fresh proposals the per-iteration clock keeps rebaselining whiletotal_elapsedgrows monotonically, so the absolute cap still fires first. - Comment elides the actual safety guarantee — Agreed. Tightened: the comment now points at the
last_seen_proposal_ts is Noneshort-circuit at the rebaseline check rather than implying any datetime/None ordering. - Naming bikeshed — Skipped. Per your "not worth renaming a public config knob just for this" caveat — agreed it's not worth the churn for a single-#-change PR.
- Pre-timeout doesn't rebaseline — Skipped. Out of scope per the post-mortem, which scoped specifically to the post-timeout kill in
issue-1557-v2. Worth filing as a follow-up if the same pattern shows up pre-timeout.
— Authored by egg
There was a problem hiding this comment.
Re-review summary
Approve. Each of the five actionable items from the prior review is addressed correctly, the validator is sound, and the regression-shaped test that had to be reworked still tests the absolute cap as advertised.
Verification of prior feedback
TypeErrorfallback dropped (pipelines.py:10693-10696) — the innertry/except TypeErroris gone; the outerexcept Exception:still defends against tracker raises. ✓getattrdefaults removed (pipelines.py:11247-11250) — now readspipeline.config.post_consensus_iteration_budget_seconds/...max_total_secondsdirectly. A future field rename will surface asAttributeError. ✓- Cross-field validator added (
models.py:623-638) —@model_validator(mode="after")rejectsmax_total < iteration_budget. Defaults (3600 / 14400) satisfy the constraint, so older serialised configs rehydrate cleanly viastate_store.py:1062 PipelineConfig.model_validate(config). ✓ - Test helper rewrite (
test_post_timeout_rebaseline.py:60) —PipelineConfig(**overrides)now goes through Pydantic validation (including the new cross-field check); the silent__dict__fallback is gone. ✓ - Comment tightened (
pipelines.py:11255-11259) — now correctly attributes the safety to thelast_seen_proposal_ts is Noneshort-circuit at line 11328 rather than implying any datetime/None ordering. ✓
Verification of the reworked test
test_absolute_cap_bounds_unbounded_proposal_churn previously used iteration_budget=5000, max_total=2500 — now invalid under the new validator. The rewrite uses iteration_budget=2500, max_total=2500. I traced the mocked monotonic call sequence to confirm the absolute cap still fires first and the test isn't accidentally exercising the iteration-budget cap:
- Calls 1-2:
start_time=0,elapsed=3801→ enters timeout path - Call 3:
post_timeout_start=4801 - Iteration 1, call 4:
now=5801,total_elapsed=1000,iteration_elapsed=1000→ both pass - Rebaseline fires (fresh proposal), call 5:
last_progress_at=6801 - Iteration 2, call 6:
now=7801,total_elapsed=3000 ≥ 2500→ break on absolute cap;iteration_elapsed=1000(rebaseline kept it small)
So the absolute-cap branch is hit, not the iteration-budget branch. The trace holds because _latest_ts always returns a strictly-fresher timestamp, so the rebaseline branch fires every loop and iteration_elapsed resets to ~1000s while total_elapsed grows monotonically.
Validator correctness
models.py:631 uses strict < (rejecting equality only when max_total < iteration_budget), and test_equal_budgets_accepted locks in max_total == iteration_budget as valid. That matches the production loop semantics: with both equal, a healthy producer rebaselines per iteration and only total_elapsed ever crosses the bound.
Test/lint sanity
pytest orchestrator/tests/test_post_timeout_rebaseline.py— 11 passedpytest orchestrator/tests/test_consensus_polling.py— 15 passed (comment-only changes)ruff checkon the touched files — clean
Non-blocking
1. Test could assert which cap fired
test_absolute_cap_bounds_unbounded_proposal_churn now asserts only exit_code == 1 and that stop_container was called. With both budgets equal, an off-by-one in the rebaseline branch — say, the per-iteration check moved above the rebaseline — could allow iteration_elapsed >= 2500 to fire first and the test would still pass. The original 5000 / 2500 split made the absolute cap the only possible terminator. Recovering that specificity without violating the validator could be done by capturing the warning log and asserting on "absolute cap reached", e.g. via caplog.records.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…e conflicts in orchestrator/models.py
Conflict Resolution SummaryResolved merge conflicts with
Please review: The auto-merge in
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review summary
Approve. This re-review covers the only delta since 91a7b4b — merge commit e5055e5 resolving conflicts with main (#2243's progress-gate landed via #2254 / #2265). The merge is semantically clean and preserves all of the previously-approved logic.
Verification of merge resolution
orchestrator/models.py — three fields cleanly co-exist:
| Field | Source | Default |
|---|---|---|
post_consensus_iteration_budget_seconds |
this PR (#2245) | 3600 |
post_consensus_max_total_seconds |
this PR (#2245) | 14400 |
brc_consensus_progress_gate_seconds |
main (#2243) | 300 |
Verified at runtime: PipelineConfig() gives iter=3600, max=14400, gate=300. The _validate_post_consensus_budgets validator survived the merge intact and still rejects max < iter (confirmed: iter=7200, max=3600 → ValidationError; iter=max=2500 accepted).
orchestrator/routes/pipelines.py — the auto-merge produces a clean three-stage layering:
- Pre-timeout: progress gate (
_check_brc_progress_gateat line 9563) defers the auto-HITL decision while bus / heartbeat activity is fresh —_progress_gate_deferringinitialized at line 10906, checked at line 11342 inside theif elapsed >= consensus_timeout:branch. - Timeout fires:
_handle_brc_consensus_timeout(...)at line 11375 (only reached once the gate stops deferring). - Post-timeout poll loop: this PR's per-iteration rebaseline kicks in (line 11410+).
_latest_proposal_tsreads the same tracker the gate consults viaget_latest_progress_timestamp, but the two helpers operate on disjoint clocks (gate on wall-clock vs. proposal age; rebaseline on monotonic vs. last-progress-at), so they don't interact except through the shared tracker.
The composition is correct: while the gate is deferring, the post-timeout loop is unreachable (we continue back to the consensus check); once the gate releases, we enter the post-timeout loop with last_progress_at = post_timeout_start — so the rebaseline starts fresh, not contaminated with the gate-deferral wall-clock spend. That's the right semantic.
Test interaction with the merged-in gate
The rebaseline tests in test_post_timeout_rebaseline.py patch peer_consensus.get_peer_consensus_tracker, which the gate also imports — so I traced the gate's behavior under the test mocks:
- Test 1 (
test_no_progress_during_wait_force_kills_at_iteration_budget): tracker patched toNone→ gate'stracker is not Noneshort-circuits → falls through to heartbeat check → no heartbeats registered for"issue-2245"/"coder"in the singleton → gate returns(False, None)→ consensus timeout proceeds. ✓ - Tests 2 & 3 (
test_fresh_proposal_extends_iteration_budget,test_absolute_cap_bounds_unbounded_proposal_churn): tracker patched to aMagicMock. The gate callstracker.get_latest_progress_timestamp()(noside_effectset) → returns auto-attributeMagicMock. Then(now_dt - ts).total_seconds() < gate_secondsraisesTypeError: '<' not supported between instances of 'MagicMock' and 'int'(verified directly in Python 3.13). The gate'sexcept Exception:catches it, logs WARN, falls through to heartbeat check → no defer.
So the gate does not deflect the rebaseline tests off the post-timeout path. The tests still exercise the production logic they're meant to.
One observation worth flagging (non-blocking): test 2/3 silently exercise the gate's exception-fallback path on every poll iteration, accumulating WARN BRC progress-gate tracker check failed log entries. The tests pass, but they're noisier than ideal and would mask a real gate-side regression. The cleaner fix is to also stub get_latest_progress_timestamp.return_value = None on the mock tracker — one line in _common_mocks would do it. Not a merge regression, just an interaction the merge made visible.
Non-blocking suggestion from prior re-review
The "assert which cap fired" suggestion from the second review (capture caplog.records and assert on "absolute cap reached" for test_absolute_cap_bounds_unbounded_proposal_churn) was not addressed in this merge. Still a non-blocking suggestion — the test does pin exit_code == 1 and stop_container is called, but with iteration_budget == max_total, an off-by-one in the rebaseline branch could let the iteration cap fire first and the test would still pass.
Files changed since 91a7b4b
orchestrator/models.py and orchestrator/routes/pipelines.py — only conflict-resolution / auto-merge content from e5055e5. No PR logic changes since the last review. pytest orchestrator/tests/test_post_timeout_rebaseline.py and test_consensus_polling.py were claimed to pass pre-merge; CI should re-confirm post-merge.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…back - test_absolute_cap_bounds_unbounded_proposal_churn now patches routes.pipelines.logger and asserts the warning message contains 'absolute cap reached' (and that 'iteration budget exhausted' did not fire). With iteration_budget == max_total the prior assertion (exit_code == 1) couldn't distinguish the two caps; this pins the branch so an off-by-one moving the per-iteration check above the rebaseline would now fail loudly. - Both rebaseline tests now stub mock_tracker.get_latest_progress_ timestamp.return_value = None so the pre-timeout progress gate (#2243) doesn't hit its exception-fallback path on the auto-attribute MagicMock and produce noisy 'BRC progress-gate tracker check failed' WARN logs that would mask a real gate-side regression. Both items called out as non-blocking suggestions in the egg-reviewer re-review of e5055e5.
|
Addressed both non-blocking suggestions from the re-reviews in 209d6c4:
Verified locally: — Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
orchestrator/routes/pipelines.py grew from 15356 to 15593 lines due to the post-timeout rebaseline changes in this PR. orchestrator/peer_consensus.py grew from 1988 to 2003 lines. scripts/select_tests.py grew from 1650 to 1850 lines due to changes merged to main in PR #2262 (bare-name AST resolver, empty-diff skip). Update all three baselines so the file-size lint passes in CI.
Autofix tracking{"Lint/Custom Checks": 1} |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…e conflict in scripts/file-size-allowlist.yaml
Conflict Resolution SummaryResolved merge conflicts with
Resolution rationale: Each side updated the baseline for the file it modified. This PR added lines to Verification:
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review summary
Approve. Both non-blocking suggestions from the prior re-review (e5055e5) are correctly addressed in 209d6c4, the file-size allowlist baseline matches reality, and the merge conflict resolution in fb32c6b is sound. No new issues.
Verification of prior feedback
-
Pin which cap fired (
test_post_timeout_rebaseline.py:360-461) —test_absolute_cap_bounds_unbounded_proposal_churnnow@patch("routes.pipelines.logger")and asserts both that"absolute cap reached"appears and"iteration budget exhausted"does not. I verified the substrings are unique log sites (pipelines.py:11426/pipelines.py:11434), so a swap of the two cap checks would be caught loudly. ✓ -
Silence gate exception-fallback noise (
test_post_timeout_rebaseline.py:329and:426) — both rebaseline tests with amock_trackernow stubget_latest_progress_timestamp.return_value = None. The gate'stracker.get_latest_progress_timestamp()no longer returns an auto-attribute MagicMock that would raiseTypeErroron(now_dt - ts).total_seconds(), so the gate'sexcept Exception:fallback no longer accumulates noisyBRC progress-gate tracker check failedWARN entries on every poll iteration. The gate now cleanly falls through to the heartbeat check (which is empty for"issue-2245"/"coder"), returns(False, None), and the post-timeout path under test is exercised as intended. ✓
Trace of the absolute-cap test under the new monotonic sequence
iteration_budget=2500, max_total=2500, monotonic returns 0, 3801, 4801, 5801, 6801, 7801, …:
- Calls 1-2:
start_time=0,elapsed=3801→ enters timeout - Call 3:
post_timeout_start=4801 - Iteration 1, call 4:
now=5801,total_elapsed=1000,iteration_elapsed=1000→ both pass; rebaseline fires (call 5:last_progress_at=6801) - Iteration 2, call 6:
now=7801,total_elapsed=3000 ≥ 2500→ break on absolute cap;iteration_elapsed=1000
So total_elapsed >= post_timeout_max_total is checked before iteration_elapsed >= post_timeout_iteration_budget in the production code (pipelines.py:11423-11440), "absolute cap reached" is the only warning emitted, and the assertions hold. The fresh-proposal test's exit-0 assertion still proves the rebaseline kept the loop alive past the original cutoff.
File-size allowlist (080fcff + fb32c6b merge)
scripts/file-size-allowlist.yaml after the conflict resolution:
| File | PR baseline | Actual wc -l -c |
Match |
|---|---|---|---|
orchestrator/routes/pipelines.py |
15593 / 681405 | 15593 / 681405 | ✓ |
orchestrator/peer_consensus.py |
2003 / 85965 | 2003 / 85965 | ✓ |
scripts/select_tests.py |
1875 / 75206 | 1875 / 75206 | ✓ |
Conflict resolution rationale (HEAD's pipelines.py baseline + main's select_tests.py baseline) is correct — each side updated only its own modified file's row. The 080fcff commit message claims "peer_consensus.py grew from 1988 to 2003 lines" but the PR doesn't actually modify peer_consensus.py — that growth came from main. Cosmetic-only, no impact on the lint or correctness.
Files changed since e5055e5
orchestrator/tests/test_post_timeout_rebaseline.py— 38 lines (test improvements only)scripts/file-size-allowlist.yaml— added with correct baselines, conflict resolved correctly
pytest orchestrator/tests/test_post_timeout_rebaseline.py was claimed to pass at 11/11 by the producer; I couldn't run it locally (sandbox lacks .venv).
— Authored by egg
|
egg review completed. View run logs 13 previous review(s) hidden. |
…e-size-allowlist.yaml Both sides bumped baselines for orchestrator/routes/pipelines.py and scripts/select_tests.py. After the merge, the actual file sizes are 15594/681452 (pipelines.py) and 1875/75206 (select_tests.py), so the baselines are set to those values. Brings in main's progress-gate (#2254), post-timeout rebaseline (#2253), select_tests AST resolver (#2262/#2266), and max-file-size lint (#2250).
Summary
post_timeout_budget = 3600inorchestrator/routes/pipelines.pywith a per-iteration clock that rebaselines on each freshCONSENSUS_PROPOSE(initial propose or NACK→re-propose), so a healthy multi-iteration BRC cycle no longer gets force-killed mid-iteration.PipelineConfigknobs:post_consensus_iteration_budget_seconds(default 3600) andpost_consensus_max_total_seconds(default 14400, hard safety cap so unbounded propose churn can't stall the pipeline).issue-1557-v2Apr 29 SDLC run, which showed the kill landed mid-iteration-3 of a productive consensus cycle.How it works
tracker.get_latest_proposal_timestamp()(already exposed onPeerConsensusTrackersince Producer can re-propose after only one reviewer NACK in multi-reviewer BRC, wasting review cycles #2142) on each iteration. A newer timestamp than the last seen one signals producer progress and resetslast_progress_at._latest_proposal_ts(pipeline_id, slice_id)) and falls back gracefully to the bare pipeline tracker for older shims, matching the pattern in_update_agents_complete.Noneon any error/missing tracker) — defaults to the pre-[#1921 follow-up] Implement phase still force-kills at 90 min — post_timeout_budget is hardcoded 3600s with no per-phase / per-complexity knob #2245 fixed-budget behaviour when the tracker isn't available, so non-BRC code paths are unaffected.Test plan
orchestrator/tests/test_post_timeout_rebaseline.pycovering: defaults, overrides, min validation, no-progress force-kill (pre-[#1921 follow-up] Implement phase still force-kills at 90 min — post_timeout_budget is hardcoded 3600s with no per-phase / per-complexity knob #2245 behaviour), fresh-proposal extension (rebaseline keeps the loop alive past the original cutoff), absolute-cap bound (rebaseline can't stall the pipeline). All 9 pass.orchestrator/tests/test_consensus_polling.pyandtest_consensus_timeout_recheck.pystill pass (22 tests). Stale3600s post-timeout budgetcomments updated to reflect the per-iteration semantics.make test-allfor broader fallout coverage.Closes #2245.