Fix #2263: per-phase consensus timeout defaults (refine/plan/implement) - #2267
Conversation
A single 30-min `consensus_timeout_minutes` was calibrated against refine
(smallest fan-out, ~1 pass) and forced implement (5 reviewers, 2-3 NACK
iterations common) to either burn the budget or trip the auto-decision /
force-kill boundary.
This adds three per-phase override fields and a phase-aware fallback
chain at the consensus polling read site:
1. `consensus_timeout_minutes_<phase>` if explicitly set, else
2. legacy `consensus_timeout_minutes` if explicitly set (preserves the
back-compat clause that pipelines passing only the global behave
identically across all three phases), else
3. PHASE_CONSENSUS_TIMEOUT_DEFAULTS_MIN — refine 30, plan 60,
implement 90.
The legacy global default flips from `30` to `None` so its "is it set?"
state is unambiguous; existing pipelines that explicitly pass a value
still see that value applied uniformly. Companion to #2243's progress
gate (which defers the decision while progress signals are fresh) and
#2245's post-timeout per-iteration clock — different layers, same goal.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…docs in concurrent-execution.md and sdlc-pipeline.md
There was a problem hiding this comment.
Review
Reviewed every changed file in the diff and traced the resolver's call sites and the field's readers across the repo. The change is well-scoped: only one direct reader of pipeline.config.consensus_timeout_minutes existed (orchestrator/routes/pipelines.py:10701, previously getattr(..., 30)) and it was migrated to the new resolver. No other code path reads the field, so the type flip from int = 30 → int | None = None is safe inside the orchestrator. The HITL decision text at pipelines.py:9732,9752 uses the local consensus_timeout (seconds), so operators see the resolved per-phase value when the timeout fires.
Tests cover the priority chain (override > legacy global > phase default), the legacy back-compat clause, the leak-prevention (per-phase doesn't bleed into others), and the unknown-phase fallback. The phase_str derivation at pipelines.py:10390 (phase if isinstance(phase, str) else phase.value) yields a member of PipelinePhase — "refine", "plan", "implement", or "pr" — and getattr(config, f"consensus_timeout_minutes_{phase}", None) falls through cleanly for "pr" (and for any future phase) via the unknown-phase branch returning 30. Pydantic's ge=1 validator on each override field guarantees raw_timeout >= 1, so the existing max(raw_timeout, 1) defensive clamp at line 10702 is now strictly redundant but harmless.
No blocking issues found.
Non-blocking suggestions
-
Stale example value in
docs/guides/sdlc-pipeline.md:1235. The sample JSON still shows"consensus_timeout_minutes": 30. With the new defaults, setting the global to 30 overrides the calibrated per-phase budgets (so plan would shrink from 60 → 30 and implement from 90 → 30) — exactly the opposite of what someone copy-pasting the example would want. Either drop that line from the example (sincenull/unset is now the recommended default) or replace it with a per-phase override to demonstrate the new shape. -
Hard-coded
30in the unknown-phase fallback (orchestrator/models.py:43).PHASE_CONSENSUS_TIMEOUT_DEFAULTS_MIN.get(phase, 30)re-encodes the legacy default outside the constant dict. If the dict ever grows a new phase or the floor is recalibrated, this number won't track it. ConsiderPHASE_CONSENSUS_TIMEOUT_DEFAULTS_MIN.get(phase, PHASE_CONSENSUS_TIMEOUT_DEFAULTS_MIN["refine"])or pulling the floor into a named constant. Minor — the current behavior is documented in the test attest_models.py:460, so it's intentional, just a brittle seam. -
Public-API schema break (advisory, not blocking). The field's type in
PipelineConfig.model_json_schema()flips from{"type": "integer"}to{"anyOf": [{"type": "integer"}, {"type": "null"}]}and the default flips from30tonull. External clients that POST configs are unaffected (omit-or-null is accepted; passing the int still works), but clients that read a serialized config and assume a non-null int — or generated TypeScript types pinned to the old schema — will see a contract change. Worth a one-liner in release notes; no code change needed. -
Override-vs-global precedence is correct but worth a doc nudge. The legend in
concurrent-execution.md:36-39andsdlc-pipeline.md:1252-1255clearly states "wins over the legacy global," but a reader who sets bothconsensus_timeout_minutes=120andconsensus_timeout_minutes_implement=60may intuit that the larger value would apply (more time to converge). The current behavior — explicit-phase always wins, even if smaller — is the right call (explicit > implicit), it's just non-obvious. A short worked example in the docs would close the gap.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
PR #2250 (file-size lint) merged after #2254 (progress gate) but its allowlist baseline wasn't updated to reflect #2254's growth. Combined with this PR's +1 line in pipelines.py the lint now fails. Update the baselines to the post-merge state (15515 lines / 677159 bytes for pipelines.py; 2003 lines / 85965 bytes for peer_consensus.py). Issue #2248 still tracks the underlying decomposition work.
Conflict Resolution SummaryResolved merge conflicts with
No source-code conflicts: Extra commit: file-size allowlist bumpLint surfaced a pre-existing drift: #2254 (progress gate, +158 lines to Verification
Please review: the timeout-handling paragraph in — Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
… lines / 73711 bytes)
Autofix tracking{"Lint/Custom Checks": 1, "Lint/Python": 3} |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
No agent-mode design concerns. The delta since my last review at 38e59ac is a docs merge resolution (folding the brc_consensus_progress_gate_seconds row from #2254 into the per-phase timeout tables and rewriting the Timeout Handling paragraph to describe gate → blocker-eval ordering) plus a file-size allowlist bump. Neither touches prompts, model IDs, agent output structure, or sandbox boundaries.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review (delta since 38e59ac)
The substantive code change (resolver + per-phase config fields + read-site swap) was already approved and is unchanged on this commit. Since 38e59ac the PR added two main commits inbound (#2250 file-size lint, #2265 progress-gate doc), one merge commit, and two allowlist baseline bumps. I verified each.
Doc merge resolution — correct
Both docs/guides/concurrent-execution.md (lines 36–41 / 724–731) and docs/guides/sdlc-pipeline.md (lines 1252–1257) cleanly fold the per-phase rows from this PR with the new brc_consensus_progress_gate_seconds row from #2265. The Timeout Handling paragraph at concurrent-execution.md:726–731 correctly orders the runtime flow as "per-phase budget elapses → progress gate → blockers eval," which matches the read site (pipelines.py:10701 resolves the per-phase budget; PeerConsensusTracker.handle_timeout() is the post-gate evaluator). No semantic loss from either side.
Allowlist bumps — two of three match main
scripts/file-size-allowlist.yaml:
orchestrator/peer_consensus.py→2003 / 85965. Main haspeer_consensus.pyat exactly 85965 bytes (gh api .../contents?ref=main). PR doesn't touch this file — bump just absorbs main's drift from #2254 that #2250 missed. ✓orchestrator/routes/pipelines.py→15515 / 677159. Main is at 677112 bytes; PR adds 1 line atpipelines.py:10376(resolve_consensus_timeout_minutesimport) plus the read-site swap, ~47 bytes — post-merge result lands at 677159, matching the new baseline. ✓scripts/select_tests.py→1850 / 73711. Stale by 1495 bytes. The bump was made after #2262 landed on main (which grew the file to 73711) but before #2266 landed (which grew it further to 75206). Current main hasselect_tests.pyat 75206 bytes; the PR isbehind_by=2against main, with #2266 as the second behind-commit. After GitHub auto-merges main into this PR (or after merge into main), the resulting tree will pair main's 75206-byteselect_tests.pywith this PR's 73711 baseline →make lintwill fail withselect_tests.py: 75206 bytes exceeds allowlist baseline (73711). Non-blocking because the PR's own tree still passes (PR'sselect_tests.pyis at 63828, well under 73711), but the next rebase against main will need another bump. Worth a follow-up commit before merge to avoid breaking lint on main.
Resolver + read site — unchanged, still correct
I re-confirmed the resolver wiring on this commit: models.py:26-43 (priority chain override > legacy global > phase default), pipelines.py:10376 import, pipelines.py:10701 consumption. Only one direct production reader of config.consensus_timeout_minutes exists outside the resolver (a test setter at test_agent_exits_recorded.py:31), so the type flip from int = 30 → int | None = None remains safe.
Previous non-blocking suggestions
Three of four suggestions from my prior review remain unaddressed (stale "consensus_timeout_minutes": 30 example at sdlc-pipeline.md:1235, hardcoded 30 fallback at models.py:43, public-API schema flip note). Author's prerogative — none are blocking. The example at sdlc-pipeline.md:1235 is now arguably worse than stale: with the new calibrated defaults, copy-pasting "consensus_timeout_minutes": 30 actively regresses plan from 60→30 and implement from 90→30. If the author is taking another commit anyway for the select_tests.py baseline, this would be a cheap addition.
No blocking issues found.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…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).
Conflict Resolution SummaryResolved merge conflicts with
DetailBoth sides bumped allowlist baselines for the same two files differently:
The auto-merged files ( Verification
Please review: the — Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
# Conflicts: # scripts/file-size-allowlist.yaml
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.
No agent-mode design concerns. The delta since my last review at fd466d10 is two merge commits from origin/main (06db62b8, 2ee6c035) — both mechanical conflict resolutions in scripts/file-size-allowlist.yaml to absorb #2274's schema change (per-file baselines dropped) and the post-merge pipelines.py byte count. The substantive code paths I reviewed previously (models.resolve_consensus_timeout_minutes, the pipelines.py:10701 read site, the per-phase config fields) are byte-identical on the new tip.
— Authored by egg
|
egg check fixer encountered an issue fixing Lint. View run logs — Authored by egg |
|
egg is investigating the Lint check failure...
|
|
egg check fixer encountered an issue fixing Lint. View run logs — Authored by egg |
|
egg is investigating the Lint check failure...
|
Autofix: Human Input NeededThe following checks could not be fixed automatically after multiple attempts:
Please investigate manually or push a fix. — Authored by egg |
|
egg is investigating the Lint check failure...
|
Autofix: Human Input NeededThe following checks could not be fixed automatically after multiple attempts:
Please investigate manually or push a fix. — Authored by egg |
|
egg is investigating the Lint check failure...
|
Autofix: Human Input NeededThe following checks could not be fixed automatically after multiple attempts:
Please investigate manually or push a fix. — Authored by egg |
|
egg is resolving merge conflicts for this PR... |
…urrent-execution.md Combined the per-phase consensus timeout config (this PR) with the consensus-timeout HITL → OVERSEER_ALERT migration (#2264 / #2277) in both guides. The two changes are complementary: per-phase fields stay; descriptions of the post-timeout escalation surface now reference OVERSEER_ALERT and link to issue #2264 to match main.
Post-merge ruff sweep (#2297 bumped ruff to v0.15.12) flagged the quoted forward reference. PEP 649 lazy evaluation lands in py3.14 (the project target), so the runtime quote is no longer required.
Conflict Resolution SummaryResolved merge conflicts with
Both sides were complementary: this PR adds per-phase overrides; #2264/#2277 swapped the post-timeout escalation surface from a HITL decision to an Post-merge cleanup
Verification
Please review: the four doc conflicts were all the same shape — keep my prose, adopt main's terminology — but they're each in slightly different framings (table row, paragraph, table row, numbered-list item). Worth a skim to confirm the merged sentences read naturally and the — Authored by egg |
|
egg conflict resolution completed. View run logs |
There was a problem hiding this comment.
No agent-mode design concerns. The delta since my last review at 2ee6c035 is two mechanical changes:
- Doc conflict resolution (
docs/guides/concurrent-execution.md,docs/guides/sdlc-pipeline.md) folding main'sOVERSEER_ALERTterminology from #2264/#2277 into this PR's per-phase timeout prose — additive, no semantic loss. orchestrator/models.py— dropped the"PipelineConfig"forward-reference quote onresolve_consensus_timeout_minutesper ruffUP037(post-#2297 sweep). Pure typing cleanup; no behaviour change.
The substantive code paths (resolve_consensus_timeout_minutes, the per-phase config fields, the pipelines.py:10894 read site) are unchanged from the previously approved commit. No prompts, model IDs, agent output structure, or sandbox boundaries touched.
— Authored by egg
|
egg agent-mode-design completed. View run logs 26 previous review(s) hidden. |
There was a problem hiding this comment.
Re-review (delta since 2ee6c03)
The substantive code (resolver, per-phase fields, read-site swap) was already approved at 38e59ac and is unchanged in functionality. Since 2ee6c035 the PR has added two PR-authored commits plus a merge resolution:
f692be3— Addresses two of my prior non-blocking suggestions:docs/guides/sdlc-pipeline.md:1232-1246— drops the stale"consensus_timeout_minutes": 30from the JSON example and replaces it with prose + a worked override example. Copy-pasters can no longer accidentally regress plan/implement back to 30. ✓orchestrator/models.py:45-47— replaces the hardcoded30fallback withPHASE_CONSENSUS_TIMEOUT_DEFAULTS_MIN["refine"], with a doc-comment explaining the choice (smallest calibrated budget = safe upper bound for unknown phases). ✓orchestrator/tests/test_models.py:458-461— test renamed and asserts against the constant. ✓
8a6a241— Doc-only conflict resolution combining per-phase tables (this PR) with the consensus-timeout-HITL →OVERSEER_ALERTmigration (#2264) on theconcurrent-execution.mdandsdlc-pipeline.mdconfig tables and the Timeout Handling section. I traced both files and the resolved narrative correctly orders runtime flow as "per-phase budget elapses → progress gate → publishOVERSEER_ALERT." No semantic loss from either side.57e9fd8— Drops thePipelineConfigforward-reference quotes per ruffUP037(post-#2297 ruff bump). Verified at runtime under py3.14: with PEP 649 lazy annotation evaluation,inspect.signature(resolve_consensus_timeout_minutes)resolves tomodels.PipelineConfigcleanly even though the function is defined before the class. The annotation is never accessed at function-definition time, so the bare reference works.
#2264 OVERSEER_ALERT integration — verified
pipelines.py:9758 (timeout_minutes = int(consensus_timeout / 60)) and pipelines.py:9802 (metadata["consensus_timeout_minutes"] = timeout_minutes) both consume the resolved per-phase consensus_timeout (from pipelines.py:10897's resolve_consensus_timeout_minutes() call). So the OVERSEER_ALERT body and metadata correctly reflect the resolved per-phase value (e.g., "BRC consensus has not converged after 90 minutes" for default-implement). The two changes compose cleanly.
Tests
I ran the relevant suites locally (system py3.14):
orchestrator/tests/test_models.py— 76 passed, including all 7TestResolveConsensusTimeoutMinutescases.test_consensus_polling.py,test_consensus_timeout_recheck.py,test_consensus_complete_with_failures.py,test_pipelines_routes.py— 49 passed.test_brc_nack_iteration.py,test_consensus_race_on_exit.py,test_short_flow_contract_reviewer.py,test_agent_exits_recorded.py,test_post_timeout_rebaseline.py— 46 passed.
No regressions from the merge.
Non-blocking observation (one new)
orchestrator/models.py:42: return override triggers mypy's no-any-return warning because getattr(config, ..., None) is typed Any. Not CI-breaking — make lint-python runs mypy on gateway shared sandbox only, not orchestrator, so this is silent today. Verified with a minimal repro: annotating override: int | None = getattr(...) clears it cleanly. Mention only because if orchestrator is ever added to the mypy frontier, this seam will flag.
Previous non-blocking suggestions
Two of four are now resolved (above). The remaining two — public-API JSON-schema flip (advisory release-notes nudge) and a worked override-vs-global precedence example in the docs — are unchanged. The new prose at sdlc-pipeline.md:1240-1246 partially addresses the precedence one with the consensus_timeout_minutes_implement: 120 example, so I'm dropping it. The schema-flip note remains advisory only.
No blocking issues found.
— Authored by egg
|
egg review completed. View run logs 26 previous review(s) hidden. |
| egg is addressing review feedback... |
getattr returns Any, so without a hint mypy flags the return on line 42. Reviewer-suggested non-blocking observation on PR #2267 — orchestrator isn't on the mypy frontier yet, but cheap insurance for when it is.
|
Agreed. Addressed the mypy Leaving the public-API JSON-schema flip as advisory release-notes only, as you suggested — no code change needed there. — Authored by egg |
|
egg feedback addressed. View run logs 26 previous review(s) hidden. |
There was a problem hiding this comment.
Re-review (delta since 57e9fd8)
Single-line delta: orchestrator/models.py:40 annotates override: int | None = getattr(...). This directly addresses the one non-blocking observation from my prior review (mypy no-any-return).
Verified
- Annotation matches the underlying field types —
consensus_timeout_minutes_{refine,plan,implement}are allint | None(models.py:420,428,436), and unknown phases fall through to thegetattrdefaultNone.int | Noneis the tight union. - Function return type
-> intstill holds:overrideis only returned insideif override is not None. git diff prev-review-tip..pr-2267 --statconfirms exactly one file, one insertion, one deletion. No other behavior touched.- Resolver semantics, per-phase fields, and the
pipelines.pyread site are unchanged from the previously approved commit.
No blocking issues. The mypy no-any-return seam is now closed pre-emptively, so when orchestrator joins the mypy frontier this won't flag.
— Authored by egg
|
egg agent-mode-design completed. View run logs 26 previous review(s) hidden. |
|
egg review completed. View run logs 26 previous review(s) hidden. |
Summary
consensus_timeout_minutesinto per-phase overrides so refine (1 producer / 2 reviewers, ~1 pass) and implement (3 producers / 5 reviewers, 2-3 NACK iterations common) can have phase-appropriate budgets without recalibrating each one against the smallest case.consensus_timeout_minutes_{refine,plan,implement}toPipelineConfig(orchestrator/models.py:377-413) and a small resolver (resolve_consensus_timeout_minutes) consumed at the consensus polling read site (orchestrator/routes/pipelines.py:10705).PHASE_CONSENSUS_TIMEOUT_DEFAULTS_MIN: refine 30 / plan 60 / implement 90.Resolution chain
The resolver picks a per-phase timeout in this order, highest priority first:
consensus_timeout_minutes_<phase>) if set — explicit phase tuning wins.consensus_timeout_minutes) if explicitly set — preserves the AC back-compat clause that pipelines passing only the global continue to behave identically across all three phases.PHASE_CONSENSUS_TIMEOUT_DEFAULTS_MIN.To make step 2's "is it set?" check unambiguous, the legacy global's default flips from
int = 30toint | None = None. The only runtime reader was the line we replaced; tests and snapshots that explicitly set the field are unaffected.Why this is cushion behind #2243's progress gate
PR #2254 (the progress gate) defers the auto-consensus-failure decision while progress signals are fresh. With the gate in place the timeout is only consulted when there's been no progress — which is the right time to fire. But:
This is a different layer from #2245's post-timeout per-iteration clock (
post_consensus_iteration_budget_seconds) — that governs time after the timeout fires; this governs time until it fires.Acceptance criteria
PipelineConfig.pipelines.pyconsumes the per-phase override when set, falls back to the global, then to the phase-aware default.docs/guides/sdlc-pipeline.mdanddocs/guides/concurrent-execution.md.Test plan
make lint— clean.make test(changeset-aware) — local run skipped per request; CI is the ground truth.TestResolveConsensusTimeoutMinutescases assert: phase-aware defaults when nothing set; legacy global applies to all phases; per-phase override wins over global; per-phase override doesn't leak into other phases; unknown phase falls back to 30 (and to the legacy global when set).Related
Closes #2263.