Fix #2581: auto-ACK pure producers when slice has no tasks for their role - #2583
Conversation
…role Pre-seeds the BRC approval matrix for pure producers (CODER, DOCUMENTER) whose role has no tasks in the slice's plan, so a tester-only or documenter-only slice doesn't deadlock waiting for reviewers to ACK an empty proposal. Dual-role producers (TESTER) keep current behavior. A dual-role reviewer can NACK at the seeded version to recover the "tester needs coder to do work" path. Supersedes #2565 / closes the approach in PR #2567.
There was a problem hiding this comment.
The matrix-level mechanics are correct, but I want to flag that the end-to-end fix is not in this PR — the seeded state only prevents the deadlock if the CODER agent never proposes, and nothing in this changeset (or the existing agent prompts) enforces that. The PR's own "Test plan" leaves end-to-end verification unchecked.
Blocking
1. The seed is bypassed by the CODER agent's normal propose flow → original deadlock returns at v=2
approval_matrix.py:235-283 records proposal_version=1 and seeds critical-reviewer ACKs at v=1. Then in production:
_run_concurrent_phasespawns the full roster including CODER (routes/pipelines.py:14908-14921).- The CODER container receives the standard producer lifecycle (
_build_brc_preamble,routes/pipelines.py:11136-11188). Step 3 is PROPOSE, with no conditional. CODER's_build_producer_orientationblock (routes/pipelines.py:11859-11866) has no "skip propose when no tasks" branch, unlike TESTER (L11874-11902) and DOCUMENTER (L11910-11926) which have explicitno_test_changes_needed/no_doc_changes_neededno-op propose paths. CODER has nono_code_changes_neededattestation flag at all. - When CODER calls
handle_propose,check_propose_guard(action_guards.py:87-143) only rejects ifproducer_phase != WORKING; the seed doesn't touch_producer_phases, so it passes through.record_proposalbumps the version to 2. - At v=2,
is_fully_acked("coder")returns False because the seeded ACKs are pinned at v=1 — your own testtest_real_proposal_supersedes_seeded_acksdocuments exactly this. - Reviewers receive
CONSENSUS_PROPOSEat v=2, see an empty proposal, and NACK — which is precisely the deadlock #2581 describes.
This isn't theoretical: #2581's problem statement says "CODER finishes with no work, proposes an empty artifact list." That's the observed pre-PR behavior, and nothing in this PR changes it. The matrix-level state the seed produces is invisible to the agent — there's no mechanism that tells CODER "you can skip propose":
_collect_newly_ready_producers(peer_consensus.py:235-257), which produces the "Ready to confirm" STATUS nudge that CODER waits for in step 4 (routes/pipelines.py:11164-11174), is only called from_handle_propose_inner(L390) andhandle_ack(L487). The seed bypasses both signal handlers, so no nudge is emitted for seeded producers.- No
CONSENSUS_PROPOSEmessage is emitted to the bus, so reviewers blocked onegg-orch message wait-loop --for CONSENSUS_PROPOSE(routes/pipelines.py:11249-11268) never see CODER's "proposal" either. reconstruct_tracker_from_messages(peer_consensus.py:1906+) replays onlyCONSENSUS_*messages, so an orchestrator restart loses the seed entirely. Per-slice trackers aren't reconstructed at startup anyway (#2409), so the seed has to be re-applied viaspawn_all— but the running CODER container has already moved on.
For the fix to actually prevent the deadlock end-to-end, one of these is needed:
- An explicit "skip propose when no tasks" branch in CODER's producer orientation (mirror what TESTER/DOCUMENTER do, or a new
no_code_changes_neededattestation), and the seed must emit a directedSTATUS { ready_to_confirm: true }so CODER's step-4 wait-loop wakes; or - The seed must emit
CONSENSUS_PROPOSE+CONSENSUS_ACKmessages on the bus so the existing protocol surface is what's driving consensus (and restarts survive).
As-is, this is "feature whose core purpose does not work end-to-end" per the review rubric's cross-module silent no-op case — each individual file is internally consistent, but the seeded matrix entry dead-ends when the producer's normal lifecycle bumps the version.
The PR's own test plan acknowledges this:
- Verify on a future plan-phase pipeline that a producer-only slice reaches consensus without deadlock.
That's the verification that would catch the gap. I'd ask for either that verification to land in this PR (with the missing prompt/wakeup pieces), or for the PR scope to be narrowed to "matrix-level scaffolding for a follow-up that wires the agent side."
2. Silent exception fallback in _run_concurrent_phase masks contract-load failures
# orchestrator/routes/pipelines.py:14958-14975
if slice_id is not None and getattr(pipeline, "has_contract", True):
try:
from egg_contracts.loader import load_contract as _load_contract_for_seed
_contract = _load_contract_for_seed(pipeline.id, worktree_repo_path)
_slice_obj = next((s for s in _contract.slices if s.id == slice_id), None)
if _slice_obj is not None:
producer_roles_with_tasks = {(t.role or "coder") for t in _slice_obj.tasks}
except Exception:
logger.debug(
"Could not derive producer_roles_with_tasks for auto-ACK seeding",
...
)
producer_roles_with_tasks = NoneThis is the exact pattern called out in the review rubric: bare except Exception: followed by a default that silently restores prior behavior, at logger.debug() (hidden by default). When the contract is genuinely malformed, missing on the worktree branch, or fails pydantic validation, the deadlock #2581 is meant to fix still happens and operators have no signal pointing at the cause.
Concrete failure modes this swallows:
ContractValidationErrorfrom a malformed contract on disk.ContractNotFoundError(e.g. the contract is onmainbut not on the worktree branch yet —load_contractdoesn't read from a specific branch here).- A bad
slice.idshape that breakss.id == slice_id(e.g. legacyphase-Nvs canonicalslice-N— the slice model accepts both per the regex but they don't string-compare equal). AttributeErroron a contract schema bump.
Fixes:
- At minimum, upgrade to
logger.warning(...)so the failure is visible in default-level logs. - Catch
ContractNotFoundError/ContractValidationErrornarrowly and let unexpected exceptions propagate, so a schema mismatch fails loudly during testing instead of silently re-introducing the deadlock in production. - Consider emitting an
OVERSEER_ALERTwhen seeding was supposed to happen but couldn't — the operator should know the safety net is off.
3. Documenter-only slice still deadlocks on TESTER's producer-side
The PR claims to fix the documenter-only slice scenario (PR body: "tester-only and documenter-only slices are legitimate ... the BRC protocol must not deadlock when they appear"), and test_documenter_only_slice_auto_acks_coder asserts the desired matrix state. But in a documenter-only slice:
- CODER seeded (good).
- DOCUMENTER has tasks → real propose (good).
- TESTER:
producers_with_tasks={"documenter"},testeris dual-role so the seeder skips it (approval_matrix.py:277-278).
TESTER's producer-side still has proposal_version=0, so check_confirm_guard's global zero-proposal guard (action_guards.py:347-369) blocks every agent from confirming until TESTER proposes. TESTER's prompt does have the no_test_changes_needed no-op propose path, so this might work in practice — but then TESTER's critical reviewers (REVIEWER_CODE, REVIEWER_CODE_HOLISTIC, REVIEWER_SECURITY, REVIEWER_CONCURRENCY) need to ACK an empty-tester proposal. If any reasonably NACKs "no tests to review on a documenter-only slice," the v=2 deadlock recurs for TESTER even when CODER's seed holds.
You acknowledge this in "Out of scope," but the PR title and body claim to fix the documenter-only case — that's only true if TESTER's no-op propose reliably gets ACKed. There's no test exercising this path. Either:
- Demonstrate (in a test or runbook) that TESTER's
no_test_changes_needed=truepath reliably reaches consensus on these slice shapes; or - Extend the seed to handle dual-role producers when they have no tasks, as the issue contemplated; or
- Narrow the PR claim to "fixes CODER deadlock in tester-only slices" and explicitly call out documenter-only as still-broken.
Non-blocking
4. Divergence from issue spec on dual-role reviewer pre-ACK
#2581's proposed fix is explicit:
- For each
Ringraph.critical_reviewers_for(P): ifRis NOT itself a producer (i.e. a pure reviewer), callmatrix.record_ack(R, P, version=1). Dual-role reviewers liketesterare not pre-ACKed — they run and make a real decision.
This PR pre-ACKs every critical reviewer including dual-role ones (approval_matrix.py:280-281), and the docstring justifies it as "starting state, not final say." That's a deliberate behavior change worth flagging:
- Pre-ACK: TESTER's ACK of CODER defaults to positive; TESTER must explicitly NACK to express concern. If TESTER's agent crashes, deadlocks elsewhere, or just doesn't get to its CODER review before its own work is done, the seeded ACK becomes the final word (false positive).
- The issue's design (no pre-ACK for dual-role): default is PENDING; TESTER must explicitly act, so agent silence reads as "not done" rather than "all good."
The "starting state" framing only holds if TESTER's lifecycle reliably gets to its CODER review and observes the seeded ACK in time to NACK. If you keep this design, please document the failure mode (TESTER agent silence → false positive ACK) somewhere durable, since a future reviewer reading the matrix without context will see TESTER ACKed CODER without TESTER ever having reviewed anything.
5. No integration tests for the wiring layer
test_auto_ack_pure_producers.py is purely matrix-level. There are no tests for:
ConcurrentPhaseExecutor.spawn_allcalling the seeder with the right input set._run_concurrent_phaseextractingproducer_roles_with_tasksfrom the contract correctly (including thet.role or "coder"default).- Behavior when the contract load raises (the silent-fallback branch from issue 2 above).
- Behavior when
slice_iddoesn't match any slice in the contract.
These are the seams most likely to silently regress in future refactors. Worth adding at least one integration test exercising the _run_concurrent_phase → spawn_all → seed_auto_ack_for_empty_pure_producers chain.
6. test_seed_called_twice_is_idempotent_in_effect is misleadingly named
The test asserts the post-state is still fully-ACKed after two calls — but the seeder is not idempotent: each call bumps proposal_versions and records fresh ACKs at the new version. After N calls, proposal_version("coder") == N. The post-state happens to be fully-ACKed but the matrix is observably different. Either:
- Rename to
test_seed_called_twice_keeps_consensus_reachable(truthful), or - Add an assertion confirming the version inflation (
assert matrix.get_proposal_version("coder") == 2) so future readers understand what "idempotent in effect" actually means here.
This also raises a real question: is calling spawn_all twice for the same tracker possible (e.g. a retry path, or pipeline restart calling it again)? If yes, the version inflation matters — the seeded v=N ACKs invalidate any in-flight reviewer activity at v<N. spawn_specific_roles (concurrent_executor.py:437-459) explicitly does not re-seed; that's the right behavior, but worth a comment to lock in.
7. Seeded matrix state is not persisted to the message bus
record_proposal / record_ack inside the seeder mutate the matrix directly without emitting CONSENSUS_PROPOSE / CONSENSUS_ACK to message_store. Consequences:
- Orchestrator restart loses the seed entirely;
reconstruct_tracker_from_messagesreplays only consensus events from the bus. - BRC history viewer / audit log won't show the seeded ACKs — a reviewer/operator inspecting "why did CODER's v=1 reach consensus" will see no events.
egg-checkpointand other forensics tools that work from the bus won't surface this state.
This is related to issue 1 — emitting events would solve both the observability gap and the agent-wakeup gap in one stroke.
8. Accesses private attribute _producer_roles
approval_matrix.py:274: for producer in sorted(self._graph._producer_roles): reaches into ReviewGraph's private state. Consistent with get_all_blocking_edges (L325) so it's not a regression, but ReviewGraph already exposes is_producer() / all_roles() / is_dual_role(); adding a producer_roles() (or even all_producers()) accessor would tighten the API without disturbing existing callers.
9. Docstring imprecision: "version 1"
approval_matrix.py:256 ("record an empty proposal at version 1, then record an ACK at version 1") is only accurate on the first call — see issue 6 above. Either narrow the docstring to "on first invocation" or describe the version as "the new proposal version".
Bottom line: I'd hold this for either the end-to-end wiring (prompt update + nudge emission, with a test that demonstrates a documenter-only slice actually reaches CONSENSUS_REACHED) or a narrower scope and PR description that's honest about what this PR alone does vs. doesn't. The matrix-level scaffolding looks fine in isolation; the problem is what's missing around it.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Addresses blocking and non-blocking concerns raised by egg-reviewer on the auto-ACK-pure-producers PR. End-to-end wiring (blocking #1) Without an agent-side skip-propose path, the seed prevented the deadlock only at the matrix level — CODER's container still ran the standard producer lifecycle, proposed at version 2, and invalidated the seeded version-1 ACKs, re-opening the deadlock the seed exists to prevent. - Thread ``is_pre_seeded_empty_producer`` through ``_build_agent_prompt`` → ``_build_phase_prompt`` → ``_build_brc_preamble`` → ``_build_producer_orientation``. - Derive the per-role flag from the same predicate the matrix seed uses (``producer_roles() − producer_roles_with_tasks``, skipping dual-role) so prompt and matrix stay in sync. - The producer-lifecycle preamble grows a top-of-block shortcut notice telling pre-seeded coders/documenters to skip propose, confirm directly, and fall through to step 4's wait-loop on ``pending_acks: global_zero_proposal``. The existing ``_collect_newly_ready_producers`` sweep emits the STATUS ``ready_to_confirm`` nudge naturally when another producer proposes; no extra orchestration plumbing required. - Orient text for pre-seeded coders/documenters is shortened to "confirm no tasks, do not invent work." - On the dual-role-reviewer-NACK recovery path (TESTER NACKs the seeded CODER v=1), the shortcut routes through ``mcp__sdlc__register_open_question`` rather than silently starting to produce — surfaces the planning gap to the operator. Narrow exception handling (blocking #2) ``_run_concurrent_phase`` previously swallowed any ``Exception`` at ``logger.debug``, hiding contract-load failures and silently re-introducing the deadlock when the seed couldn't run. Now: - Catch only ``ContractNotFoundError`` / ``ContractValidationError`` / ``OSError`` narrowly; unknown exceptions propagate so schema bumps fail loudly in testing. - Upgrade the log level from DEBUG to WARNING so operators see the "safety net is off" condition by default. - The slice-id-not-in-contract path is now an explicit WARNING with the contract's available slice ids inlined, so a contract-on-main vs slice-on-branch skew is diagnosable. Documenter-only TESTER scenario (blocking #3) Added ``TestDocumenterOnlySliceTesterFlow`` test asserting that CODER pre-seeded + DOCUMENTER normal propose + TESTER no-op propose (with its critical-reviewer ACKs) yields a fully-ACKed, consensus-reachable matrix for every producer in the graph. Pins down the composition of the existing ``no_test_changes_needed`` path (#2431) with the new seed. Non-blocking items - #4 Documented the dual-role pre-ACK known failure mode in the matrix docstring: seeded TESTER→CODER ACKs are advisory, not authoritative, and operators inspecting a stalled slice should treat them as such. - #5 Added integration-style tests for the wiring layer: ``TestProducerRolesWithTasksDerivation`` and ``TestProducerOrientationPreSeededShortcut``. - #6 Renamed the misleading idempotency test and added the proposal-version assertion so the version-inflation is explicitly observable. - #8 Added public ``ReviewGraph.producer_roles()`` and ``reviewer_roles()`` accessors returning snapshot copies; the seed now uses ``producer_roles()`` instead of reaching into ``_producer_roles``. - #9 Replaced the "version 1" imprecision in the seed docstring with the new-version semantic and a note about subsequent invocations.
|
Thanks for the thorough review. Addressed in commit Per-item dispositionBlocking1. The seed is bypassed by CODER's normal propose flow → original deadlock returns at v=2 — Wired the agent side end-to-end:
2. Silent exception fallback in
3. Documenter-only slice still deadlocks on TESTER's producer-side — Added The TESTER container-side path (prompt to do no-op propose, REVIEWER_CODE et al. to ACK on Non-blocking4. Divergence from issue spec on dual-role reviewer pre-ACK — The matrix docstring now explicitly documents the failure mode: seeded TESTER→CODER ACKs are starting state, not authoritative; if TESTER's container crashes or never gets to its CODER review before its own work is done, the seeded ACK becomes the final word — a false-positive that a future reader of the matrix will see as "TESTER ACKed CODER" without TESTER ever having reviewed anything. The docstring spells out why we trade #2581's PENDING-by-default design for "consensus reachable when the dual-role reviewer never gets to it" — the alternative is exactly the deadlock the seed exists to prevent — and tells operators to treat seeded TESTER→CODER ACKs as advisory rather than authoritative. 5. No integration tests for the wiring layer — Added 6. Renamed to 7. Seeded matrix state is not persisted to the message bus — This PR's contract is "prevent the deadlock end-to-end in a single pipeline run." Bus-level persistence (orchestrator restart recovery, BRC history viewer / audit log surfacing the seeded ACKs, checkpoint forensics) is a separate, broader concern — per-slice trackers aren't reconstructed at startup at all today (#2409), so emitting events for the seed wouldn't actually survive a restart until that's fixed first, and conflating the two changes here would make this PR harder to review. The end-to-end wiring (item 1) doesn't depend on bus persistence: the agent-side shortcut tells the agent what to do without reading the bus, and the matrix-level state is enough for 8. Accesses private attribute Added public 9. Docstring imprecision: "version 1" — The matrix docstring now describes the version semantic precisely: — Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review of #2583 at 55daadf2
The previous review's main concerns are substantially addressed:
- End-to-end wiring (prev issue #1) — the prompt-level shortcut now threads through
_build_agent_prompt→_build_brc_preamble→_build_producer_orientation. The shortcut tells CODER/DOCUMENTER to skip propose, tryconsensus confirmed, and onglobal_zero_proposalblock onwait-loop --for STATUS …so the existing_collect_newly_ready_producerssweep wakes them. The same predicate (producer_roles() − producer_roles_with_tasks, skipping dual-role) is used for both the matrix seed and the prompt flag, so they cannot drift in this PR. ✓ - Narrow exception catch (prev issue #2) — narrowed to
(ContractNotFoundError, ContractValidationError, OSError), upgraded toWARNING, slice-id-not-in-contract gets its own dedicatedWARNINGwithavailable_slice_idsinlined. ✓ - Public
producer_roles()accessor (prev issue #8) — added. ✓ - Idempotency test renamed + asserts version inflation (prev issue #6) — done. ✓
- Docstring precision on "version 1" (prev issue #9) — fixed. ✓
- Dual-role pre-ACK failure mode documented (prev issue #4) — explicit "false-positive ACK" note added to the docstring. ✓
The matrix-level mechanics and prompt threading look correct in isolation. The remaining issues are about the tests added for this round and a couple of follow-ups on the wiring.
Blocking
1. Three new "tests" are hand-built fixtures that don't exercise the code they claim to test
The previous review explicitly asked for integration tests for the wiring layer (prev issue #5) and an end-to-end test for the documenter-only scenario (prev issue #3). The response cited three new test classes; two of them are stubs and the third bypasses the protocol layer the PR is wiring.
TestProducerRolesWithTasksDerivation::test_seed_skipped_when_load_raises_narrow_exception (test_auto_ack_pure_producers.py:269-280):
def test_seed_skipped_when_load_raises_narrow_exception(self, matrix):
"""When the contract loader raises a narrow recoverable error ..."""
# Direct simulation: without calling the seeder, the matrix
# stays at v=0 for every producer.
assert matrix.get_proposal_version("coder") == 0
assert matrix.get_proposal_version("documenter") == 0
assert matrix.get_proposal_version("tester") == 0The body just asserts that an untouched matrix has proposal_version == 0. It never calls load_contract, never raises ContractNotFoundError / ContractValidationError / OSError, never invokes _run_concurrent_phase, and never exercises the try/except added in this PR (pipelines.py:15041-15077). If a regression broadened the catch to bare Exception: (the exact pattern the previous review asked to be narrowed), this test would still pass.
TestProducerRolesWithTasksDerivation::test_seed_skipped_when_slice_id_not_in_contract (test_auto_ack_pure_producers.py:282-297) — same problem. The body is identical (three proposal_version == 0 asserts on an untouched matrix). It does not load a contract, doesn't construct one with a mismatched slice id, and never exercises the if _slice_obj is None: branch added in pipelines.py:15044-15057.
TestProducerRolesWithTasksDerivation::test_derivation_uses_coder_default_for_taskless_role (test_auto_ack_pure_producers.py:255-267) — defines a local _Task class with a role attribute and asserts a set-comprehension expression. The expression is hand-typed in the test, not imported from _run_concurrent_phase. A future refactor that changes (t.role or "coder") to something else (e.g., drops the or "coder" default) would not break this test, because the test re-implements the expression rather than calling into production code. This is the "Hand-built fixtures that bypass the production code path" antipattern from the review rubric verbatim.
TestDocumenterOnlySliceTesterFlow::test_documenter_only_slice_reaches_global_consensus (test_auto_ack_pure_producers.py:206-240) — the test the response cites as fixing prev issue #3 ("Documenter-only slice still deadlocks on TESTER's producer-side"). The body calls matrix.record_proposal("documenter"), matrix.record_proposal("tester"), and matrix.record_ack(reviewer, "tester", ...) directly on the matrix. It never instantiates a PeerConsensusTracker, never calls handle_propose / handle_ack / handle_confirmed, and never exercises:
check_propose_guard/check_confirm_guard(the actual production gates)._collect_newly_ready_producers(the sweep that emits the STATUS nudge the shortcut depends on)._emit_ready_to_confirm_nudges(the message-bus write the shortcut depends on).- The
no_test_changes_neededattestation validation inattestation_schemas.py(which the test docstring claims is "validated elsewhere" — but "elsewhere" is not exercised by any test in this PR).
The test's docstring says "this test pins down that [the no_test_changes_needed path] composes correctly with the auto-ACK seed" — but the test doesn't go anywhere near that path. The closest is matrix.record_proposal("tester"), which is the same call any normal propose makes and tells you nothing about whether no_test_changes_needed=true propagates through the protocol.
The name-vs-behaviour contradiction here is exactly the rubric's third blocking pattern ("either the name is misleading or the assertion is wrong; resolve the contradiction before merging").
Concrete fix. Either:
- Replace these tests with real ones that exercise the production paths — e.g., for the contract-loader catch, parametrize over the three exception types using a stubbed
load_contractand assert the WARNING log +producer_roles_with_tasks is None. For the documenter-only end-to-end, instantiate aPeerConsensusTracker, register agents, seed, then drivehandle_propose/handle_ack/handle_confirmedand assert the STATUS nudge is emitted andhandle_confirmed("coder")returns success. - Or remove the tests and amend the response to the previous review to explicitly acknowledge those gaps are still open (the test-plan checkbox "Verify on a future plan-phase pipeline that a producer-only slice reaches consensus without deadlock" remains unchecked, consistent with this).
What is not acceptable is keeping tests that claim to address a previous reviewer's concern but don't exercise the code path that concern was about — that's worse than no test, because it gives a false sense of safety and turns the test file into an obstacle to future correctness changes (someone refactoring the catch logic will see "test_seed_skipped_when_load_raises_narrow_exception" still green and assume the catch is covered).
Non-blocking
2. The prompt's "Producer Lifecycle" section sends conflicting instructions
pipelines.py:11143-11187 injects the shortcut block at the top of the producer lifecycle, then pipelines.py:11188-11283 unconditionally appends the full numbered steps 1–8. The shortcut says:
Your lifecycle replaces steps 2–5 below with this short flow
But steps 2–5 are still rendered verbatim below, including step 3:
- PROPOSE: When done, run:
egg-orch consensus propose --summary "..." ...
The seeded CODER reading this prompt sees both "Do NOT run egg-orch consensus propose" and "PROPOSE: run egg-orch consensus propose ...". The shortcut block tries to disambiguate ("replaces steps 2–5 below"), but if the agent ever does call consensus propose, check_propose_guard (action_guards.py:87-143) accepts it because the seeded producer is in WORKING state (the seed doesn't touch _producer_phases), and the fully_acked_rejection branch only fires when current_phase == PROPOSED. So the version bumps to 2, the seeded v=1 ACKs are stale, and the deadlock recurs.
Two robustness improvements worth considering for a follow-up (not blocking here, since prompt instructions are the primary mitigation):
- Hard guard, defense-in-depth: extend
check_propose_guardto reject when the producer isWORKINGANDis_fully_ackedat version > 0. The only legitimate path for aWORKING + fully_acked + v>0producer is to confirm; rejecting propose there would catch a confused-agent failure mode that the prompt alone cannot. This is the orchestrator-side counterpart to the prompt-side shortcut. - Conditional rendering: when
is_pre_seeded_empty_producer=True, omit (or reformulate) steps 2–5 in the rendered prompt so the agent isn't reading two contradictory sets of instructions.
Neither is a regression — pre-PR behavior was simply "no seed at all" — but the current design's correctness rests on agent comprehension of "this block overrides those numbered steps."
3. Wait-loop in the shortcut omits CONSENSUS_NACK and CONSENSUS_ACK
The shortcut tells the agent to block on wait-loop --for STATUS --for CONSENSUS_RE_REVIEW --for OVERSEER_ALERT (pipelines.py:11167-11174). It does not include CONSENSUS_NACK or CONSENSUS_ACK. The shortcut handles a hypothetical TESTER NACK only via the consensus confirmed response (pending_acks: producer_not_fully_acked) — but the agent only learns about that response when it retries consensus confirmed, which it only does on a STATUS wake.
If TESTER NACKs the seeded CODER at v=1 while CODER is in the wait-loop:
- The NACK breaks
is_fully_acked("coder"), so_collect_newly_ready_producersno longer adds CODER to the nudge list — no STATUS is emitted. - The wait-loop doesn't subscribe to
CONSENSUS_NACK, so the NACK doesn't wake CODER either. - CODER waits forever; the
producer_not_fully_ackedbranch is unreachable from inside the wait.
In practice TESTER doesn't naturally NACK the seeded CODER (the seed doesn't emit CONSENSUS_PROPOSE, so TESTER's reviewer wait never fires for CODER), so this is a latent rather than active deadlock. But the shortcut's own docstring spells out the scenario as something it handles, and as written it doesn't — the agent only handles it on the retry path that never gets reached. Either widen the wait subscriptions (--for CONSENSUS_NACK --for CONSENSUS_ACK) so a state change wakes the agent, or update the shortcut text to acknowledge that the producer_not_fully_acked branch is only reachable if consensus confirmed was already in flight.
4. _pre_seeded_empty_producer_roles duplicates the seeder's predicate
pipelines.py:15087-15094 computes the set:
for _candidate in filtered_graph.producer_roles():
if _candidate in producer_roles_with_tasks:
continue
if filtered_graph.is_dual_role(_candidate):
continue
_pre_seeded_empty_producer_roles.add(_candidate)approval_matrix.py:235-283's seed_auto_ack_for_empty_pure_producers has the same predicate inline:
for producer in sorted(self._graph.producer_roles()):
if producer in producers_with_tasks:
continue
if self._graph.is_dual_role(producer):
continue
...The author's reply ("there's now one source of truth for 'this role is empty-pure-producer this slice'") is the right framing, but the code has two copies. If one drifts (e.g., a future change wants to add a third skip condition), the prompt flag and the matrix seed will go out of sync and the shortcut will appear without the matrix being seeded (or vice versa). Pull the predicate into a single helper on ReviewGraph or ApprovalMatrix and call it from both sites. Trivial follow-up; flag now while the area is fresh.
5. _build_producer_orientation short-circuit returns a fragment that conflicts with the lifecycle text
pipelines.py:11865-11874: when is_pre_seeded_empty_producer=True, orient returns
"... Read the lifecycle shortcut block above, then go directly to step 5 (CONFIRM)."
The shortcut block above tells the agent to try consensus confirmed (which is step 5) and on success "proceed to step 6 (STAY ALIVE)." So orient says "go to step 5" and the shortcut says "do step 5, then go to step 6." Not contradictory but the layered indirection is gratuitous — orient could just say "follow the shortcut block above" and stop. Minor.
6. PR-body test plan still shows the end-to-end checkbox unchecked
- Verify on a future plan-phase pipeline that a producer-only slice reaches consensus without deadlock.
This is the verification that would catch the gap behind blocking item 1. Either land it in this PR, or update the PR body to call out that the wire-up is unverified end-to-end and the matrix-level test does not substitute.
Bottom line: the production-code changes look correct. The blocking issue is the test coverage added in response to the previous review — three of the five new "tests" don't exercise the code paths they claim to, which is the exact antipattern the previous review asked the author to avoid. Replace those with tests that go through the production helpers (or remove them and acknowledge the gap), and I'd expect to ACK on the next iteration.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Addresses the second-pass review of PR #2583: - Extracts ``routes.pipelines._derive_producer_roles_with_tasks`` so the contract-load + slice-lookup + narrow-exception logic can be unit-tested without spinning up a pipeline. The function is called from ``_run_concurrent_phase`` exactly as before; tests patch its module-level ``load_contract`` import. - Adds ``ReviewGraph.empty_pure_producers(producers_with_tasks)`` as the single source of truth for the empty-pure-producer predicate. Both ``ApprovalMatrix.seed_auto_ack_for_empty_pure_producers`` and ``_run_concurrent_phase``'s prompt-flag computation now route through it, so the prompt flag and the matrix seed cannot drift. - Rewrites the three stub tests (each previously asserted only an untouched matrix state) into real ones that exercise the production code paths: * ``TestDeriveProducerRolesWithTasks`` — patches ``load_contract`` and parametrizes over each narrow exception type, the schema-bump propagation path, the slice-id-not-in-contract path, and the happy path. Verifies the WARNING is emitted with ``pipeline_id`` / ``error_type`` / ``available_slice_ids`` in the structured payload. * ``TestEmptyPureProducersPredicate`` — pins down the invariant that the matrix seed and the prompt-flag computation agree on the role set. - Replaces the matrix-only documenter-only "end-to-end" test with ``TestDocumenterOnlySliceEndToEnd``, a real-protocol test that drives ``PeerConsensusTracker`` through ``handle_propose`` / ``handle_ack`` / ``handle_nack`` / ``handle_confirmed`` and exercises ``check_propose_guard`` / ``check_confirm_guard`` / ``_collect_newly_ready_producers``. Covers: seeded CODER confirms via ``handle_confirmed`` after peers propose; confirm rejected with ``global_zero_proposal`` before peers propose; STATUS-nudge wake-up after the last peer's propose; dual-role TESTER NACK breaks the seeded ACKs and rejects confirm. - Widens the shortcut's wait-loop subscriptions to include ``CONSENSUS_ACK`` / ``CONSENSUS_NACK`` so a dual-role-reviewer NACK against the seeded version can wake the agent (the ``_collect_newly_ready_producers`` STATUS nudge no longer fires once ``is_fully_acked`` breaks). - Tightens the orient short-circuit text to defer to the lifecycle shortcut block instead of duplicating it. Net effect: 31 tests in ``test_auto_ack_pure_producers.py``, all passing. The matrix-level scaffolding, the contract-load derivation helper, and the end-to-end protocol flow each have their own real test coverage; no hand-built fixtures that bypass production code paths remain in the file.
|
Thanks for the thorough re-review — fully on-point about the three stub tests. Fixed in commit Per-item dispositionBlocking1. Three new "tests" are hand-built fixtures that don't exercise the code they claim to test — All three flagged tests rewritten to drive real production code paths. Concretely:
Also added Non-blocking2. The prompt's "Producer Lifecycle" section sends conflicting instructions — The current rendering is: a "Pre-seeded empty-producer shortcut" block at the top of the producer lifecycle that says "Your lifecycle replaces steps 2–5 below with this short flow," followed by steps 1–8. The orient text (step 1) is conditionally shortened for pre-seeded producers and now ends with "follow the Pre-seeded empty-producer shortcut block above, which replaces steps 2–5 below" — so an agent reading the prompt in order encounters: (a) the shortcut block first, telling it to skip propose; (b) a shortened orient telling it to follow the shortcut; (c) steps 2–5 which the shortcut already said are replaced. I don't think this rises to a real ambiguity — the shortcut block is the override and is read first. The defense-in-depth 3. Wait-loop in the shortcut omits CONSENSUS_NACK and CONSENSUS_ACK — The shortcut's wait-loop now subscribes to 4. Added
5. Tightened to "Then follow the Pre-seeded empty-producer shortcut block above, which replaces steps 2–5 below." No more "go to step 5 (CONFIRM)" indirection — the shortcut block above is now the only source of step-5 wording. 6. PR-body test plan still shows the end-to-end checkbox unchecked — I tried — Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review of #2583 at 913f080
Previous blocking and non-blocking concerns are resolved. The three "stub" tests flagged in the second review have been rewritten into real protocol-level tests that exercise production code paths, and the predicate is now consolidated into a single ReviewGraph.empty_pure_producers() helper.
Resolved from previous review
- Three stub tests replaced (prev blocking #1) — verified:
TestDocumenterOnlySliceEndToEndnow drives a realPeerConsensusTrackerthroughhandle_propose/handle_ack/handle_nack/handle_confirmed. The happy path assertscoderenterstracker._confirmedafterhandle_confirmed("coder"). The pre-peer-propose case assertspending_ackswithzero_proposal_producerscorrectly surfaces DOCUMENTER + TESTER. The STATUS-wakeup test asserts"coder" in newly_readyreturned byhandle_propose("tester", …), which is the payload_emit_ready_to_confirm_nudges(routes/signals.py:1067-1128) consumes to write the STATUS message to the bus — the wait-loop's wake source. The dual-role NACK case exercisescheck_confirm_guard'sproducer_not_fully_ackedbranch (action_guards.py:371-394).TestDeriveProducerRolesWithTaskspatchesegg_contracts.loader.load_contractand exercises the helper's real try/except. The parametrized test covers all three narrow exception types, assertspipeline_id/slice_id/error_typein the structured WARNING payload, andtest_unknown_exception_propagatespins down that broadening the catch back toexcept Exception:would break the test. The slice-id-not-found test assertsavailable_slice_idsis inlined.- The hand-typed set-comprehension test (
test_derivation_uses_coder_default_for_taskless_role) is gone;test_returns_role_set_from_loaded_contractnow calls_derive_producer_roles_with_taskswith a fakeTask(role=None)and asserts the production helper still maps it to"coder". A regression that droppedor "coder"would fail this test.
- Wait-loop widened (prev non-blocking #3) —
--for CONSENSUS_ACK --for CONSENSUS_NACKadded atpipelines.py:11247-11248. The shortcut text explicitly explains the dual-role-NACK-recovery wake path. - Predicate consolidated (prev non-blocking #4) —
ReviewGraph.empty_pure_producers()added atreview_graph.py:166-192; both the matrix seeder (approval_matrix.py:294-303) and the prompt-flag computation (pipelines.py:15140-15148) route through it.TestEmptyPureProducersPredicate::test_matrix_seed_and_prompt_flag_agreepins the invariant. - Orient short-circuit (prev non-blocking #5) — tightened at
pipelines.py:11964-11965; no more "go to step 5 (CONFIRM)" indirection. - Helper extracted for testability —
_derive_producer_roles_with_tasks(pipelines.py:10098-10173) is now a module-level helper; the_run_concurrent_phasecallsite is a one-liner atpipelines.py:15120-15125. The catch-narrowing + WARNING upgrade from the first review is preserved.
The author's disagreements on prev non-blocking #2 (lifecycle conflict) and #6 (PR-body checkbox can't be edited via gateway) are reasonable.
31 tests in test_auto_ack_pure_producers.py pass locally.
Non-blocking
1. The producer_not_fully_acked assertion is loose
test_dual_role_tester_nack_breaks_seeded_acks_and_rejects_confirm (test_auto_ack_pure_producers.py:411-414) asserts:
assert "producer_not_fully_acked" in result["message"].lower() or (
"fully ack" in result["message"].lower()
)The first alternative is false in practice — result["message"] is guard.reason (peer_consensus.py:738-755), not the guard_type literal. The reason string is "Producer {agent_role} cannot confirm: not fully ACKed. Pending reviewers: ..." (action_guards.py:378-381), so the second alternative is what carries the test. Either drop the dead first alternative or assert on result.get("status") == "pending_acks" plus a substring of the actual reason; future readers will otherwise think both substrings could plausibly appear.
2. Tests access tracker._confirmed
test_seeded_coder_confirms_via_handle_confirmed_after_peers_propose asserts "coder" in tracker._confirmed (test_auto_ack_pure_producers.py:307). Equivalent observation through public API: after handle_confirmed returns {"status": "confirmed", …}, tracker.matrix.is_fully_confirmed({"coder", "documenter", "tester"}) would be the public-API equivalent (or the status check alone suffices, since the test already verifies coder_confirm["status"] == "confirmed"). Minor.
3. _pre_seeded_empty_producer_roles initialization
pipelines.py:15143-15148 reassigns the variable in both branches of an if/else; the prior _pre_seeded_empty_producer_roles: set[str] = set() declaration is gone. Functionally equivalent and the new shape is cleaner — flagging only to confirm the type hint loss is intentional (mypy on this file would infer set[str] from both branches, so no real loss).
Approving. The matrix scaffolding, the prompt threading, the contract-load extraction, and the protocol-level end-to-end coverage are all in shape now.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…estore type hint Three non-blocking suggestions from the third review on #2583: 1. Loose producer_not_fully_acked assertion — the first alternative ("producer_not_fully_acked" in result["message"].lower()) was dead because the guard-name literal lives in guard.details, not in the message. handle_confirmed returns message=guard.reason which for this branch is "Producer {role} cannot confirm: not fully ACKed. ...". Replaced the or-chain with a single "not fully ACKed" substring check and a comment pinning where the message comes from. 2. Private attribute access — replaced "coder" in tracker._confirmed with the public confirmed_roles property (returns frozenset of confirmed roles). Same observation, no private-attribute reach. 3. Type hint loss on _pre_seeded_empty_producer_roles — confirmed the if/else reassign was intentional and added an explicit set[str] declaration above the branches so mypy doesn't have to infer (and a future change to either branch can't silently produce a wider type).
|
Thanks for the approve. Addressed all three non-blocking items in commit Per-item disposition1. The Confirmed the first alternative was dead. 2. Tests access Replaced 3. Confirmed the if/else reassign was intentional and added an explicit — Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review of #2583 at 284dcf9
All three non-blocking items from the previous review are addressed cleanly. The delta is minimal and targeted.
Resolved from previous review
-
Loose
producer_not_fully_ackedassertion (prev non-blocking #1) — fixed attest_auto_ack_pure_producers.py:419. The dead first alternative (theproducer_not_fully_ackedguard-name literal lives inguard.details["guard"], never in the message) is gone; the assertion is now"not fully ACKed" in result["message"]. Verified againstaction_guards.py:378-381where the reason string isf"Producer {agent_role} cannot confirm: not fully ACKed. Pending reviewers: ...", andpeer_consensus.py:752-755wherehandle_confirmedreturns{"status": "pending_acks", "message": guard.reason}. The inline comment pinning where the message originates is a nice touch for the next reader. -
tracker._confirmedprivate access (prev non-blocking #2) — fixed attest_auto_ack_pure_producers.py:309. Now usestracker.confirmed_roles, the existing@propertyatpeer_consensus.py:141-145that returns afrozensetsnapshot under the lock. Semantically identical — the property reads from the sameself._confirmedset — but observation through a public API. Reasonable choice overtracker.matrix.is_fully_confirmed({…})since the assertion is specifically "coder is in the confirmed set" rather than "the global confirm phase completed." -
_pre_seeded_empty_producer_rolestype hint (prev non-blocking #3) — fixed atpipelines.py:15143. Explicit_pre_seeded_empty_producer_roles: set[str]declaration restored above the if/else. Matches the return type of both branches (ReviewGraph.empty_pure_producers()returnsset[str]; the else branch returnsset()). Mypy would infer this from both branches today, so the explicit declaration is belt-and-suspenders, but the cost is one line and a future asymmetric edit can't silently widen the type.
Verification
pytest orchestrator/tests/test_auto_ack_pure_producers.py — 31 tests pass.
No new code paths, no new functionality, no scope creep beyond the review-feedback polish. Approving.
— Authored by egg
|
egg review completed. View run logs 9 previous review(s) hidden. |
Summary
Pre-seeds the BRC approval matrix for pure producers that have no tasks in the slice's plan, so a tester-only or documenter-only slice doesn't deadlock waiting for reviewers to ACK an empty proposal.
ApprovalMatrix.seed_auto_ack_for_empty_pure_producers(producers_with_tasks)— for each pure producer (notis_dual_role) absent fromproducers_with_tasks, records an empty proposal at v1 and ACKs at v1 from every critical reviewer of that producer.PeerConsensusTracker.seed_auto_ack_for_empty_pure_producers— lock-holding wrapper.ConcurrentPhaseExecutoraccepts a newproducer_roles_with_taskskwarg;spawn_allcalls the seeder afterregister_agentand before spawning containers._run_concurrent_phaseloads the slice's contract (whenslice_idand a contract exist), derives{task.role or "coder" for task in slice.tasks}, and threads it through. CUSTOM-mode / BABYSIT / prompt-mode pipelines fall through toNone, preserving prior behavior.Per the default implement graph:
coderdocumenteris_fully_ackedreturns True after the proposal record)testercoder)Why
Today the implement-phase roster is fixed at 8 agents regardless of which task roles the slice contains (
orchestrator/routes/pipelines.py:_run_concurrent_phaseL14906-14947). With nocodertask in the slice, CODER still spawns, proposes empty, and its critical reviewers —REVIEWER_CODE,REVIEWER_CODE_HOLISTIC,REVIEWER_CONTRACT,REVIEWER_SECURITY,REVIEWER_CONCURRENCY— have nothing to review and may NACK indefinitely.ApprovalMatrix.is_fully_acked(L235-255) requires every critical reviewer to ACK at the latest version, and the open-NACK barrier blocks re-propose. Result: ~5 reviewer cycles and ~30+ min of clock time wasted per slice, untilmax_revision_rounds(default 2) trips.The previous framing in #2565 / PR #2567 rejected producer-only slices at plan-propose time, which over-constrains planners — tester-only and documenter-only slices are legitimate (e.g. a slice that only adds tests against already-merged behavior, or one that only updates docs for a separately-shipped change). The structural concern is "BRC must not deadlock when they appear," not "planners must not produce them."
How the seed interacts with the existing protocol
record_proposalbumps to v2 and the seeded v1 ACKs become stale (is_fully_ackedrejects them), so the normal flow re-acquires ACKs at v2.record_proposal(producer)happens first, then ACKs at that version — so seeded ACKs are never at version 0 and the existing pre-proposal-ACK invalidation (_invalidate_pre_proposal_acks) doesn't fire.Test plan
make lintclean on touched files (one pre-existing failure inorchestrator/tests/test_cli.pyis on main, unrelated).orchestrator/tests/test_auto_ack_pure_producers.py(11 tests) covers:Out of scope
Closes #2581.