Skip to content

Fix #2243: progress gate before BRC consensus-failure HITL decision - #2254

Merged
jwbron merged 3 commits into
mainfrom
egg/issue-2243-brc-progress-gate
Apr 29, 2026
Merged

Fix #2243: progress gate before BRC consensus-failure HITL decision#2254
jwbron merged 3 commits into
mainfrom
egg/issue-2243-brc-progress-gate

Conversation

@jwbron

@jwbron jwbron commented Apr 29, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add a progress gate in _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 within brc_consensus_progress_gate_seconds (default 300s).
  • New aggregate accessor PeerConsensusTracker.get_latest_progress_timestamp() (and ApprovalMatrix.get_latest_entry_timestamp()) gives the gate a single signal to read.
  • Heartbeats are filtered to roles in the current phase's active_executions so cross-phase pollution in the singleton HealthMonitor can't keep the gate deferring forever.
  • Default consensus_timeout_minutes stays 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_ALERT migration (step 3) remain follow-ups.

Test plan

  • Unit tests for get_latest_progress_timestamp (no-activity → None; proposal-only; ACK advances past proposal).
  • Unit tests for _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.
  • CI: make lint / make test-all on the PR.

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.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
    continue

When 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 on

or 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

@james-in-a-box

This comment has been minimized.

@james-in-a-box

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.

@james-in-a-box james-in-a-box Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the careful read. Addressed in 292a5a6:

Fixed

  1. now_mono rename — now now_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.
  2. Empty active_role_names — added an explicit if not active_role_names: return False, None early-return before the heartbeat block, and dropped if active_set and so the body's contract matches the comment. Added test_empty_active_roles_does_not_defer_on_heartbeat to lock it in.
  3. 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.
  4. Dead TypeError fallback — dropped in both _check_brc_progress_gate and _handle_brc_consensus_timeout since get_peer_consensus_tracker(pipeline_id, slice_id=None) already accepts both shapes.
  5. >=> — test now uses > with a comment explaining microsecond resolution makes back-to-back calls reliably strictly increasing.
  6. 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 rebase blocked on a merge driver) can starve them and re-open the pre-fix gap.

Deferred (non-blocking, tracked)

  1. Polling-loop wiring coverage — agreed the splice in _run_concurrent_phase isn't exercised by TestBrcProgressGate. I left this for the per-phase-timeout follow-up since _run_concurrent_phase is 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.
  2. Operator visibility while deferring — leaving for the step-3 OVERSEER_ALERT migration 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

@james-in-a-box

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.
@james-in-a-box

Copy link
Copy Markdown
Contributor
Autofix tracking
{"Test/Unit Tests": 1}

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. now_mono rename — addressed. Renamed to now_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.
  2. Empty active_role_names short-circuit — addressed. Explicit early return at pipelines.py:9655 (if not active_role_names: return False, None) and the loop body at pipelines.py:9665 is now if agent_id not in active_set: continue (the misleading if active_set and … is gone). New test_empty_active_roles_does_not_defer_on_heartbeat (test_pipelines_routes.py:286-302) locks the contract in.
  3. Same-role cross-phase pollution — addressed. TODO at pipelines.py:9613-9620 documents 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.
  4. Dead TypeError fallback — addressed. Dropped in both _check_brc_progress_gate (pipelines.py:9635) and _handle_brc_consensus_timeout (pipelines.py:9704). Verified peer_consensus.get_peer_consensus_tracker(pipeline_id, slice_id=None) accepts both shapes natively — the fallback was indeed unreachable.
  5. >=> test assertion — addressed. test_peer_consensus_integration.py:332 now uses > with a comment justifying it on datetime.now(UTC) microsecond resolution.
  6. Heartbeat-cadence contract — addressed. Docstring section at pipelines.py:9594-9602 calls out that decision-17's catchment depends on heartbeats firing within gate_seconds and a long-running uninterruptible subprocess (e.g. git rebase on a merge driver) re-opens the pre-fix gap. Good operator-facing surface.
  7. Polling-loop wiring coverage / operator visibility while deferring — explicitly deferred in the response, tracked under the per-phase-timeout / OVERSEER_ALERT follow-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_id pattern lives at pipelines.py:10814-10821 and pipelines.py:11008-11011 — both inside _run_concurrent_phase, both calling get_peer_consensus_tracker with slice_id. The current signature can't raise TypeError, 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

@james-in-a-box

Copy link
Copy Markdown
Contributor

egg review completed. View run logs

4 previous review(s) hidden.

@jwbron
jwbron merged commit ea8803d into main Apr 29, 2026
21 checks passed
james-in-a-box Bot pushed a commit that referenced this pull request Apr 29, 2026
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.
james-in-a-box Bot pushed a commit that referenced this pull request Apr 29, 2026
…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).
jwbron added a commit that referenced this pull request Apr 29, 2026
#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>
jwbron added a commit that referenced this pull request Apr 29, 2026
… 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>
jwbron added a commit that referenced this pull request Apr 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant