Fix #2243: progress gate before BRC consensus-failure HITL decision - #2254
Conversation
At ``consensus_timeout_minutes`` (default 30) the orchestrator opened a ``choice`` decision unconditionally — including in cases where producers were minutes from their first commit (decision-15/decision-17 on ``issue-1557-v2``). "Continue waiting" was the only correct answer in every recorded misfire. Add a progress gate in the polling loop in ``_run_concurrent_phase``: before opening the decision, defer if any of the following has fired within ``brc_consensus_progress_gate_seconds`` (default 300s): * ``CONSENSUS_PROPOSE`` (latest proposal timestamp on the tracker) * ACK / NACK on the approval matrix (new ``get_latest_entry_timestamp``) * container heartbeat (filtered to active roles to avoid cross-phase pollution in the singleton ``HealthMonitor``) The polling loop keeps polling on defer; the decision opens only once the bus and containers have both gone quiet for the gate window. ``brc_consensus_progress_gate_seconds=0`` disables the gate. Failures in either signal collector are logged at WARNING and treated as "no signal from that source" — never as a defer — so a crashed collector can't silently keep us off the HITL surface.
There was a problem hiding this comment.
Reviewed the diff end-to-end and traced the gate's interaction with _run_concurrent_phase, the singleton HealthMonitor, and the PeerConsensusTracker. The design is sound: the gate is purely additive (deferring an existing decision, never opening one), the two-source signal (BRC bus + container heartbeats) maps cleanly to the documented decision-15 / decision-17 misfires, and the failure-mode handling (collector crashes never silently keep the gate on) is the right call. No blocking issues.
A handful of non-blocking observations and questions below.
Non-blocking
1. now_mono is wall-clock, not monotonic — routes/pipelines.py:9594
now_mono = time.time()time.time() is wall-clock. The arithmetic is correct because _last_heartbeat is also populated from time.time() (health_monitor.py:268, 288, 294, 408), but the variable name actively misleads. Rename to now_wallclock or just now_epoch. Don't mix it with now_dt — at minimum a comment that they're deliberately two different clocks.
(Aside: because both ends of the heartbeat comparison are wall-clock, an NTP step on the orchestrator host can make (now - latest_hb) negative or skip the gate window. Real but very narrow; calling out for the per-phase-timeout follow-up.)
2. Empty active_role_names accepts all heartbeats — routes/pipelines.py:9626
active_set = set(active_role_names)
...
if active_set and agent_id not in active_set:
continueWhen active_set is empty, the and short-circuits and the filter is skipped, so every stale heartbeat in the singleton HealthMonitor counts. Today this can't fire because _run_concurrent_phase exits earlier when there are no live containers, but the contract reads inverted from the comment two lines above ("filters out cross-phase pollution"). Either explicitly:
if not active_set:
return False, None # nothing to gate onor drop the if active_set and so an empty set means "match nothing."
3. Same-role cross-phase pollution slips past the filter
The role-name filter handles the different-role case (refiner heartbeat lingering during a coder phase, which is what the test covers). But coder reappears in implement / implement-fix / fix-on-PR phases, and _last_heartbeat["coder"] is only popped on clear_agent_state (i.e. a respawn). If a coder from a prior phase emitted a heartbeat seconds before that phase ended, the next phase's coder inherits a "live" heartbeat it didn't earn.
In practice 30 min of consensus_timeout elapses before this matters, so it's unlikely to bite — but a phase boundary clear (or stamping the heartbeat key with the phase) would close it. Worth a TODO referencing #2243 step 2.
4. Defensive TypeError fallback is dead code — routes/pipelines.py:9608-9611
try:
tracker = get_peer_consensus_tracker(pipeline_id, slice_id)
except TypeError:
tracker = get_peer_consensus_tracker(pipeline_id)peer_consensus.get_peer_consensus_tracker(pipeline_id, slice_id=None) already accepts both shapes (peer_consensus.py:1783-1787). The fallback was carried over from _handle_brc_consensus_timeout (routes/pipelines.py:9676-9679) where the same dead branch lives. Drop the fallback in both places, or at least don't propagate it to new code.
5. Test assertion >= contradicts the test name — test_peer_consensus_integration.py:325
def test_ack_advances_progress_timestamp_past_proposal(self, tracker):
...
assert progress_ts >= proposal_ts>= allows equality, which would pass even if the ACK didn't advance the timestamp at all (e.g. if get_latest_entry_timestamp ever changed to return the proposal's record timestamp by accident). The name says "advances ... past proposal." Use > so the test would actually catch a regression where ACKs stop advancing progress. datetime.now(UTC) has microsecond resolution, so back-to-back calls are reliably strictly increasing.
6. No integration coverage for the polling-loop wiring
TestBrcProgressGate exercises _check_brc_progress_gate in isolation, but nothing tests the actual splice in _run_concurrent_phase (lines 11283-11320) — the time.sleep(poll_interval); continue branch, the once-per-state-transition logging, the _progress_gate_deferring flag's reset path. _run_concurrent_phase is hard to unit-test, but a small test patching _check_brc_progress_gate to return (True, "x") for N iterations then (False, None) and asserting the loop kept polling instead of calling _handle_brc_consensus_timeout would catch a regression where the gate's continue is removed or misplaced.
7. Operator visibility while the gate is deferring is one INFO log
If the bus/heartbeats stay alive for, say, 2 hours past consensus_timeout, the operator only sees the single "Consensus timeout deferred by progress gate" line that fired at the first defer. There's no surface saying "we're 90 min past nominal timeout, gate still deferring." Not blocking — OVERSEER_ALERT migration is the step-3 follow-up — but consider an egg-orch progress query / status field so an operator polling pipeline state can see the gate is active.
8. Heartbeat dependency is load-bearing for the decision-17 path
The PR's claim is that container heartbeats catch decision-17 (coder mid-merge-conflict before any CONSENSUS_PROPOSE). The gate window is 300s, so the agent must emit something (PROGRESS, HEARTBEAT message, or progress update via routes/progress.py) at least every 5 minutes to keep the gate engaged. If a stuck-but-alive agent stops emitting heartbeats during a long subprocess (e.g. git rebase blocked on a merge driver), the gate falls open and we're back to the pre-fix behavior. Worth explicitly documenting the heartbeat-cadence contract this gate relies on, ideally with a pointer to wherever sandbox heartbeats are scheduled.
<!-- has-suggestions -->
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- Rename now_mono → now_wallclock and document the two deliberate
clocks (datetime UTC vs time.time epoch); both ends of the
heartbeat comparison are wall-clock, not monotonic, so an NTP
step is a known narrow gap (callout for the per-phase-timeout
follow-up).
- Empty active_role_names now early-returns False, matching the
contract in the comment ("filters out cross-phase pollution");
previously the `if active_set and ...` short-circuited and
accepted every stale heartbeat.
- Drop the dead TypeError fallback around get_peer_consensus_tracker
in both _check_brc_progress_gate and _handle_brc_consensus_timeout;
the function already accepts (pipeline_id, slice_id=None).
- Add a TODO calling out same-role cross-phase pollution (coder
reappearing in implement / implement-fix / fix-on-PR) — the
role-name filter handles different-role ghosts but not same-role
ones, tracked under #2243 step 2.
- Document the heartbeat-cadence contract the decision-17 path
depends on (gate falls open if heartbeats stop firing within
gate_seconds).
- Tighten test_ack_advances_progress_timestamp_past_proposal from
>= to > so the test catches a regression where ACKs stop
advancing progress (back-to-back datetime.now(UTC) calls are
reliably strictly increasing).
- Add test_empty_active_roles_does_not_defer_on_heartbeat to lock
in the empty-list contract.
There was a problem hiding this comment.
Thanks for the careful read. Addressed in 292a5a6:
Fixed
now_monorename — nownow_wallclock, with a comment block explaining both clocks are deliberately wall-clock and an NTP step is a known narrow gap to revisit alongside per-phase timeouts.- Empty
active_role_names— added an explicitif not active_role_names: return False, Noneearly-return before the heartbeat block, and droppedif active_set andso the body's contract matches the comment. Addedtest_empty_active_roles_does_not_defer_on_heartbeatto lock it in. - Same-role cross-phase pollution — added a
TODO(#2243 step 2)in the docstring describing the gap (coder reappearing across implement / implement-fix / fix-on-PR) and that per-phase timeouts will likely subsume it. - Dead
TypeErrorfallback — dropped in both_check_brc_progress_gateand_handle_brc_consensus_timeoutsinceget_peer_consensus_tracker(pipeline_id, slice_id=None)already accepts both shapes. >=→>— test now uses>with a comment explaining microsecond resolution makes back-to-back calls reliably strictly increasing.- Heartbeat-cadence contract — added a docstring section explicitly calling out that the decision-17 path depends on heartbeats firing within
gate_seconds, and that a long uninterruptible subprocess (e.g.git rebaseblocked on a merge driver) can starve them and re-open the pre-fix gap.
Deferred (non-blocking, tracked)
- Polling-loop wiring coverage — agreed the splice in
_run_concurrent_phaseisn't exercised byTestBrcProgressGate. I left this for the per-phase-timeout follow-up since_run_concurrent_phaseis hard to unit-test in isolation; the helper-level tests catch the gate logic, and the splice itself is small and reviewable. Happy to add a patched-iteration test if you'd like before merge. - Operator visibility while deferring — leaving for the step-3
OVERSEER_ALERTmigration as you suggested. The single INFO log on first defer keeps log volume bounded; the step-3 work will surface gate state on the operator dashboard.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
The TypeError fallback in _handle_brc_consensus_timeout was deliberately removed in 292a5a6 ('Drop the dead TypeError fallback... the function already accepts (pipeline_id, slice_id=None)'), but the corresponding test still expected two get_peer_consensus_tracker calls (raise + retry). Production code now makes a single call, catches TypeError, and falls back to the HITL escalation path — exactly what the warning log line in the failing run shows. Drop the obsolete test.
Autofix tracking{"Test/Unit Tests": 1} |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-reviewed the delta f2a682a4..a15ccf34. The two follow-up commits (292a5a6 addressing review feedback, a15ccf3 dropping the now-obsolete test) faithfully address the previous concerns and don't introduce new issues.
Prior feedback verification
now_monorename — addressed. Renamed tonow_wallclock(pipelines.py:9627) with the explicit comment block calling out that both clocks are wall-clock and the NTP-step gap is acknowledged for the per-phase-timeout follow-up. Comment is concrete enough to survive code drift.- Empty
active_role_namesshort-circuit — addressed. Explicit early return atpipelines.py:9655(if not active_role_names: return False, None) and the loop body atpipelines.py:9665is nowif agent_id not in active_set: continue(the misleadingif active_set and …is gone). Newtest_empty_active_roles_does_not_defer_on_heartbeat(test_pipelines_routes.py:286-302) locks the contract in. - Same-role cross-phase pollution — addressed. TODO at
pipelines.py:9613-9620documents the gap (coder reappearing across implement / implement-fix / fix-on-PR) and points at #2243 step 2. Reasonable to defer; the role-name filter handles the different-role case the PR was scoped to. - Dead
TypeErrorfallback — addressed. Dropped in both_check_brc_progress_gate(pipelines.py:9635) and_handle_brc_consensus_timeout(pipelines.py:9704). Verifiedpeer_consensus.get_peer_consensus_tracker(pipeline_id, slice_id=None)accepts both shapes natively — the fallback was indeed unreachable. >=→>test assertion — addressed.test_peer_consensus_integration.py:332now uses>with a comment justifying it ondatetime.now(UTC)microsecond resolution.- Heartbeat-cadence contract — addressed. Docstring section at
pipelines.py:9594-9602calls out that decision-17's catchment depends on heartbeats firing withingate_secondsand a long-running uninterruptible subprocess (e.g.git rebaseon a merge driver) re-opens the pre-fix gap. Good operator-facing surface. - Polling-loop wiring coverage / operator visibility while deferring — explicitly deferred in the response, tracked under the per-phase-timeout /
OVERSEER_ALERTfollow-ups. Reasonable.
On a15ccf3 (stale test removal)
test_typeerror_falls_back_to_pipeline_scope in test_slice_run_loop_integration.py was correctly removed: it asserted exactly two calls (raise + retry), but with the fallback dropped the production code makes a single call and lets the outer except Exception: at pipelines.py:9716 catch any residual TypeError. Verified the catch path falls through to the standard HITL-escalation branch (elif not _brc_handled: at pipelines.py:9737, which calls _persist_hitl_decision with the standard "Consensus not reached after N minutes" question) — i.e. graceful degradation, not a crash. The remaining tests in TestHandleBrcConsensusTimeoutSliceId still exercise both the slice and pipeline-scope forwarding paths, so coverage of the live behavior is intact.
Non-blocking nits
- Sibling dead-code TypeError fallbacks still present. The same
try / except TypeError / retry without slice_idpattern lives atpipelines.py:10814-10821andpipelines.py:11008-11011— both inside_run_concurrent_phase, both callingget_peer_consensus_trackerwithslice_id. The current signature can't raiseTypeError, so they're dead branches in the same way the two now-removed fallbacks were. Not in this PR's diff — flagging as a small follow-up so they don't keep propagating to new code.
No blocking issues. Approving.
— Authored by egg
|
egg review completed. View run logs 4 previous review(s) hidden. |
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.
…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).
#2269) * Fix #2264: replace consensus-timeout HITL decision with OVERSEER_ALERT The platform should not open a side-effect-bearing `choice` decision on its own initiative when consensus times out. Operators already have the levers (`cancel_task`, `restart_phase`, `provide_input`), and "Continue waiting" was the only correct answer in every documented misfire (#2243 post-mortem, #2245 force-kill cycle). Replace the two `_persist_hitl_decision` calls in `_handle_brc_consensus_timeout` with an `OVERSEER_ALERT` publication that carries the metadata schema specified in the issue (`anomaly_type`, `phase`, `blocking_agents`, `latest_proposal_at`, `latest_heartbeat_at`, `consensus_timeout_minutes`, `priority`, plus `slice_id` when present). Critical-blocker escalation maps to `priority=high`; the no-tracker / tracker-import-error fallback maps to `priority=medium`. The existing `_emit_event(CONSENSUS_TIMEOUT)` on the fallback path is preserved. The SDLC skill's existing `OVERSEER_ALERT` flow surfaces these as non-blocking notifications (Check agent logs / Acknowledge / Cancel pipeline). No skill changes needed. Sequenced as step 3 of #2243 (step 1 progress gate landed in #2254; step 2 per-phase defaults filed separately). Migration note: any operator-side automation watching `pending_decisions` for the strings "BRC consensus failure" / "Consensus not reached after N minutes" must migrate to scanning `recent_messages` for `OVERSEER_ALERT` with `metadata.anomaly_type == "consensus-timeout"`. * Address PR #2269 review feedback - Subject now uses the first blocking-agent role (fallback to phase) so the SDLC skill's "Check agent logs" parser can extract a real role and call get_container_logs against the affected agent. - Escalate path narrows alert metadata.blocking_agents to the tracker's critical_blockers — advisory roles are excluded so the high-priority signal isn't diluted. - Body drops the misleading provide_input clause (no decision is opened by this alert post-#2264) and notes the ~60-min force-kill budget so "continues to poll" reads correctly. - Proposal-lookup and heartbeat-lookup warnings now log with exc_info=True so a stack trace survives — matches the publish helper's outer except. - Add direct unit tests for _latest_active_role_heartbeat covering empty roles, recent heartbeat, inactive-role filtering, monitor-unavailable, no-heartbeats, and the exc_info logging path. - Add escalate-path fallback test for the case where the tracker result omits critical_blockers. - Drop unused _capture_alerts(monkeypatch_target=None) parameter and refresh outdated "auto-HITL decision" / "decision-15 / decision-17 fired" comments to reflect the post-#2264 alert flow. - Docs (concurrent-execution.md, sdlc-pipeline.md) document the agent_role subject slot and drop provide_input from the timeout troubleshooting note. * Update file-size allowlist baselines for PR #2269 changes orchestrator/routes/pipelines.py grew due to this PR's new helpers (_publish_consensus_timeout_alert, _latest_active_role_heartbeat). orchestrator/peer_consensus.py and scripts/select_tests.py baselines reflect current main-branch sizes merged into the CI check commit. * Address review feedback: refresh stale HITL/decision-17 wording - orchestrator/models.py: update consensus_timeout_minutes and brc_consensus_progress_gate_seconds field descriptions to reference OVERSEER_ALERT (post-#2264) instead of HITL escalation/decision. - orchestrator/routes/pipelines.py (_check_brc_progress_gate docstring): reword the heartbeat-cadence contract paragraph to call out the coder-mid-merge-conflict path directly, marking decision-17 as the pre-#2264 historical name. No semantic change. - scripts/file-size-allowlist.yaml: bump pipelines.py baseline by 1 line / 77 bytes for the docstring change. --------- Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com> Co-authored-by: james-in-a-box[bot] <246424927+james-in-a-box[bot]@users.noreply.github.com>
… post-ACK threshold (#2268) * Fix #2242: alive-signal gate on heartbeat/progress alerts; plan-phase post-ACK threshold Heartbeat-stall and progress-stall alerts fired prematurely on plan-phase producers during long-form Anthropic completions: the agent is mid-draft, no tool calls in flight, so no `mcp__brc__send_heartbeat` arrives on the bus. On `issue-1557-v2` this escalated to a 3-of-3 producer-silence alert at 355s while every producer was simply composing its draft. Separately, the 180s post-ACK confirmation timeout was tight for plan-phase reconciliation (12 resolved decisions, 6 feedback bodies, slice-DAG sanity passes). Apply two fixes, both leaning on primitives shipped in #2254: 1. Alive-signal gate at the per-agent alert sites. Before firing `heartbeat_timeout` or `progress_stall`, consult `PeerConsensusTracker.get_latest_progress_timestamp()` plus peer-heartbeat snapshots; defer if either has fired within `orchestrator_alert_progress_gate_seconds` (default 300s, 0 disables). Self-excluded so a solo silent agent still escalates. The escalated flag is intentionally not set on defer, so the next poll re-checks. 2. Phase-aware post-ACK confirm timeout. Plan phase now uses `orchestrator_plan_post_ack_confirmation_timeout_seconds` (default 300s); refine/implement keep the existing 180s default. Out of scope (call out in #2059 follow-ups): - "Anthropic completion in flight" SDK telemetry — the cleanest fix for the heartbeat detector, but requires SDK work; the alive-signal gate covers the common case at far lower cost. - Voluntary "I'm finalizing" heartbeats that reset the post-ACK timer — same SDK-side dependency. - Auto-attaching log-tail evidence to OVERSEER_ALERT messages. Same-role cross-phase pollution caveat documented in the existing `_check_brc_progress_gate` TODO applies here too: a phase-stamped heartbeat key would close both at once. * Fix checks: disable alive-signal gate in multi-agent stall tests The 4 failing tests in TestMultipleAgentsStalling create multiple agents where one or both are deliberately stalled. The new alive-signal gate (#2242) defers per-agent stall alerts when peer agents have heartbeats within orchestrator_alert_progress_gate_seconds (default 300s), which caused these tests to observe zero escalations instead of the expected per-agent escalations. Set orchestrator_alert_progress_gate_seconds=0 in the four failing tests so they isolate the per-agent escalation behavior from the peer-progress deferral, matching the pattern already used in test_health_monitor.py::test_heartbeat_timeout_per_agent. * Update file-size-allowlist baselines for peer_consensus.py and routes/pipelines.py Both files grew past their recorded baselines in the allowlist, causing the file-sizes custom check to fail on PR #2268. Update baselines to the actual current sizes so CI passes. - orchestrator/peer_consensus.py: 1988→2003 lines, 85268→85965 bytes - orchestrator/routes/pipelines.py: 15356→15514 lines, 669950→677112 bytes * Update file-size-allowlist baseline for select_tests.py PR #2262 grew scripts/select_tests.py from 1650 -> 1850 lines without updating the allowlist baseline (#2250 added the lint after PR #2262 was reviewed, so its CI did not catch the drift). Without this update, the merged result fails 'make lint-custom'. * Address review feedback: clarify BRC-bus self-deferral; filter peer heartbeats by active-agent set Reviewer flagged three actionable non-blocking concerns on #2268: 1. Docstring discrepancy. The PR description claims "self-excluded gate so a solo silent agent still escalates", but self-exclusion only applies to the peer-heartbeat path. ``get_latest_progress_timestamp`` aggregates proposals + matrix entries across the whole tracker, so on a single-producer pipeline the producer's own propose/ACK timestamp defers its own alert until ``gate_seconds`` elapses past that timestamp (effective stall window ≈ ``heartbeat_threshold + gate_seconds``). ``CONTAINER_STOPPED`` covers genuinely-dead containers; the delay only matters for hung processes inside live containers. Filtering by focal agent would require a new ``peer_consensus`` API — deferred. Docstring updated to call out the behavior explicitly. 2. Cross-phase heartbeat pollution. Reviewer noted the symmetry argument: ``_check_brc_progress_gate`` filters peer heartbeats by ``active_role_names`` to drop stale prior-phase heartbeats from the shared HealthMonitor; this gate did not. Added a snapshot of ``set(self._agents.keys())`` taken under the same lock as ``_last_heartbeat`` so prior-phase ghosts (agents whose containers were stopped without ``reset_agent``) cannot defer current-phase alerts. Same-role cross-phase pollution remains, tracked by the existing TODO. New regression test: ``test_gate_filters_inactive_agent_heartbeats``. 3. Misleading test override. ``test_check_brc_progress_uses_plan_phase _threshold`` set ``orchestrator_alert_progress_gate_seconds=0`` with a comment about isolating the phase-aware threshold path — but ``check_brc_progress`` doesn't consult ``_has_recent_peer_progress``, so the override implied a coupling that doesn't exist. Removed. Two other reviewer concerns left as-is: the sliced-pipeline tracker scope is acknowledged in the docstring (peer-heartbeat fallback covers it), and the file-size allowlist drift was absorbed by main and is now moot. * Active-role filter pulls from tracker graph, not self._agents Reviewer noted in 76c3aac that the active-agent filter in _has_recent_peer_progress is a no-op in production: every heartbeat write also populates _agents, so set(self._agents.keys()) is a static superset of _last_heartbeat.keys() and the filter never fires. Replace it with the tracker graph's all_roles() — the current-phase roster installed by concurrent_executor.spawn_active_phase_agents. The graph IS phase-scoped, so cross-phase ghosts in _last_heartbeat (which the singleton HealthMonitor doesn't reset on phase transition) are now actually dropped. When no tracker is registered (early startup, between phases, non-BRC phases) the filter is skipped — preserves the pre-#2242 peer-heartbeat fallback behavior. Update test_gate_filters_inactive_agent_heartbeats to register AGENT_ID_2 via _emit_heartbeat (production state shape) and mock a tracker whose graph excludes it, instead of injecting an impossible state into _last_heartbeat directly. * Fix docstring symbol name: spawn_active_phase_agents → spawn_all The reviewer flagged that concurrent_executor.spawn_active_phase_agents does not exist — the actual phase-spawn entry point is ConcurrentExecutor.spawn_all (concurrent_executor.py:349). Update both references in health_monitor._has_recent_peer_progress to point at the real method so docstring navigation lands at the right symbol. * Fix docstring symbol name: ConcurrentExecutor → ConcurrentPhaseExecutor The actual class in orchestrator/concurrent_executor.py is ConcurrentPhaseExecutor (line 113); ConcurrentExecutor does not exist outside test-file local aliases. Both docstring references in health_monitor.py now point at the real symbol so Sphinx :meth: resolves and code-navigation works. --------- Co-authored-by: james-in-a-box[bot] <246424927+james-in-a-box[bot]@users.noreply.github.com> Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
…t) (#2267) * Fix #2263: per-phase consensus timeout defaults (refine/plan/implement) 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. * Bump file-size allowlist baseline for pipelines.py / peer_consensus.py 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. * Fix file-size lint: bump select_tests.py baseline to match main (1850 lines / 73711 bytes) * Address reviewer suggestions on per-phase consensus timeout - Drop stale 'consensus_timeout_minutes: 30' from JSON example in docs/guides/sdlc-pipeline.md. With the new per-phase defaults (refine 30 / plan 60 / implement 90), copy-pasting that value would actively regress plan and implement back to 30. Replaced with prose explaining the unset-default behaviour and a worked example showing per-phase override precedence. - Replace hardcoded '30' fallback in resolve_consensus_timeout_minutes with PHASE_CONSENSUS_TIMEOUT_DEFAULTS_MIN['refine'] so the unknown- phase branch tracks the constant if the floor is ever recalibrated. - Update test_unknown_phase_falls_back_to_30 to reference the constant rather than the magic number 30, matching the production code's source of truth. * Drop forward-ref quotes on PipelineConfig per ruff UP037 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. * Annotate resolver override to clear mypy no-any-return 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. --------- Co-authored-by: james-in-a-box[bot] <246424927+james-in-a-box[bot]@users.noreply.github.com> Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
Summary
_run_concurrent_phase's polling loop that defers the auto consensus-failure HITL decision while BRC bus signals (CONSENSUS_PROPOSE/ ACK / NACK) or container heartbeats have fired withinbrc_consensus_progress_gate_seconds(default 300s).PeerConsensusTracker.get_latest_progress_timestamp()(andApprovalMatrix.get_latest_entry_timestamp()) gives the gate a single signal to read.active_executionsso cross-phase pollution in the singletonHealthMonitorcan't keep the gate deferring forever.consensus_timeout_minutesstays 30 — the gate is purely additive.Why
Per the issue post-mortem, decision-15 fired ~3 min before plan-phase consensus completed; decision-17 fired 12 min before coder's first commit landed. "Continue waiting" was the only correct answer in every recorded misfire, so the platform shouldn't have opened the decision in the first place.
The gate covers both documented misfires: BRC-bus signals catch decision-15 (plan phase had recent ACK traffic), and container heartbeats catch decision-17 (coder was alive mid-merge-conflict-resolution before its first
CONSENSUS_PROPOSE).Failures in either collector are logged at WARNING and treated as "no signal from that source" — a crashed collector can't silently keep us off the HITL surface.
This is step 1 of the three-step direction in #2243; per-phase timeouts (step 2) and
OVERSEER_ALERTmigration (step 3) remain follow-ups.Test plan
get_latest_progress_timestamp(no-activity → None; proposal-only; ACK advances past proposal)._check_brc_progress_gate: disabled gate, recent proposal defers, stale proposal does not, recent heartbeat defers when bus is silent, stale heartbeat does not, inactive-role heartbeat ignored, no signals → no defer, tracker collector failure logged + no defer, heartbeat collector failure logged + no defer.make lint/make test-allon the PR.