feat(orchestrator): validate producer draft at canonical path on propose (#3016) - #3019
Conversation
…ose (#3016) A refine/plan producer that committed its draft to a non-canonical path (observed: a refiner writing .egg-state/agent-outputs/refiner-refine.md instead of .egg-state/drafts/<n>-analysis.md) reached BRC consensus and completed the phase, after which the operator gate reported "No analysis draft was found on the work branch" — a silent deterministic-input failure the read-side fixes (#2992/#2723) can't catch, since a draft at a path nobody reads is invisible to git show HEAD: of the canonical path too. Validate at consensus_propose (the universal refine/plan choke point), before handle_propose records the proposal: git show the canonical draft at the proposed commit and reject (400) when it is absent or empty, so the still-alive producer re-proposes with the draft at the right path. Closes the silent-pass hole in _validate_planner_role_alignment (plan) and adds the equivalent guard for refine (which had none). Existence-only by design; format conformance stays with reviewers/gate to avoid false rejections. Closes #3016.
There was a problem hiding this comment.
Reviewed the diff and traced the full propose → presence-check → tracker mutation path. The PR closes a real bug: a producer that committed the draft to a non-canonical path (the actual .egg-state/agent-outputs/refiner-refine.md example) used to CONFIRM consensus and complete the phase, leaving the operator gate to report an empty draft a phase later. Validating at CONSENSUS_PROPOSE against the path every downstream reader (_get_draft_path) uses is the right choke point, and placing the check before handle_propose correctly preserves the "rejected proposal never mutates the tracker" invariant established for _validate_tester_check_coverage (#1459) and _validate_planner_role_alignment (#2527).
No blocking issues. A few non-blocking concerns and minor parity notes below.
Non-blocking
1. The "non-zero git show reliably means path absent at commit" claim breaks when _verify_commit_on_branch returns None
orchestrator/routes/signals.py:1127-1129 (docstring) and the corresponding behavior at signals.py:1191-1202 assume the prior _verify_commit_on_branch call has fetched the commit. But _verify_commit_on_branch returns None (non-blocking) on either a failed git fetch origin -- {branch} (signals.py:378-385) or a failed git branch -r --contains (signals.py:404-411). In handle_consensus_propose_signal:1365-1395, that None is silently discarded — only False short-circuits with a 409.
If the fetch failed and the commit isn't already in the worktree's local object cache, git show {commit}:{draft_rel} exits with returncode 128 and stderr fatal: invalid object name / fatal: bad revision. The new validator treats this the same as a clean "path absent at commit" and raises a Refine proposal rejected: no analysis draft found at … 400. The producer's error message will incorrectly accuse them of a path problem when the orchestrator simply couldn't reach the remote.
Self-healing on re-propose is plausible (the next attempt's fetch may succeed), but the misleading 400 burns an agent turn and produces a noisy log on a transient orchestrator-side failure. Two ways to address:
- Thread the result through: have
handle_consensus_propose_signalcapturebranch_verifiedand pass it into_validate_producer_draft_present. Whenbranch_verified is None, skip the presence check (same posture as the existing "could not verify branch" warning — don't penalize the producer for an orchestrator-side glitch). - Distinguish the two failure modes via stderr inspection: parse
result.stderrforbad revision/invalid object nameand silently degrade in that case, while still rejecting onpath 'X' does not exist in 'COMMIT'.
The first option is the minimal change and matches the existing graceful-degradation philosophy elsewhere in the function.
2. _validate_planner_role_alignment's "plan not present → silent skip" is now unreachable via the production handler path
signals.py:1035-1036 (pre-existing) returns silently when git show of the plan draft is non-zero — that branch is the original bug surface this PR closes. With the new presence guard chained in front of it at signals.py:1420-1428, any task_planner proposal whose git show returns non-zero is rejected by _validate_producer_draft_present before reaching the alignment validator, so the silent-skip branch in alignment is dead code on the production path. It's still reachable when alignment is called directly (tests), but the two validators now have opposite responses to the same returncode != 0 signal:
_validate_producer_draft_present: reject (this PR)_validate_planner_role_alignment: silent pass
A future refactor that re-orders, parallelizes, or extracts these guards could quietly re-introduce the #3016 bug by losing the presence-check precedence. Either tightening alignment to raise (consistent with the new posture) or adding a one-line comment at signals.py:1035 noting "presence is now enforced upstream by _validate_producer_draft_present" would protect the invariant.
3. Minor test-parity gaps
The new TestProducerDraftPresentValidation class is missing two cases the analogous TestPlannerRoleAlignmentValidation class has:
- No
test_accepts_when_plan_draft_present(only refine —test_pipeline_prompts.py:2493). The end-to-endtest_rejected_proposal_does_not_mutate_trackerexercises the plan-present path indirectly via call 3 returning non-empty stdout (test_pipeline_prompts.py:2403-2408), but a direct unit test would lock the symmetry. - No
test_skips_when_pipeline_lookup_fails(the planner suite has it attest_pipeline_prompts.py:2338). Theexcept StateStoreError: returnbranch atsignals.py:1142-1144is uncovered.
4. Stale comment in adjacent code (pre-existing, easy drive-by)
signals.py:1004 says "16k-line routes.pipelines" — the file is now 24,348 lines (the new function's comment at signals.py:1149-1150 correctly says "21k-line"). Worth aligning the two while you're touching this region.
What I verified
- Scoped to
refiner(sole refine producer) andtask_planner(canonical plan-draft owner).architect/risk_analystwrite JSON to.egg-state/agent-outputs/and don't own the canonical plan path — confirmed againstagent_roles.py:362-455and the_synthesize_plan_draftdocstring atpipelines.py:18603-18617. Out-of-scope by design, matches the PR description. _get_draft_path("refine")→.egg-state/drafts/{prefix}-analysis.md,_get_draft_path("plan")→.egg-state/drafts/{prefix}-plan.md,_get_draft_path("implement")→None(pipelines.py:5152-5179). The defensiveif not draft_rel: returncovers the implement case if the function is ever called with a non-refine/non-plan phase.- The integration test's subprocess
side_effectlist is correctly extended from 3 → 4 entries with calls 3 and 4 both returning the misassigned plan, so the presence guard passes and the alignment guard raises (test_pipeline_prompts.py:2395-2415). Matches the production sequence. - All CI checks green at the time of review.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…ion (#3016) Address review feedback from egg-reviewer on PR #3019: 1. Thread `branch_verified` through to `_validate_producer_draft_present`. When `_verify_commit_on_branch` returns None (fetch or branch-contains errored — orchestrator-side glitch, not producer fault), the commit may not be in the local object cache. A subsequent `git show {commit}:{path}` would return 128 with 'bad revision' rather than 'path absent at commit', so running the presence check in that state would mis-blame the producer. Handler captures the tri-state from _verify_commit_on_branch and passes through; on the verifier raising, we also degrade to None. 2. Add a comment in `_validate_planner_role_alignment` noting that the silent-skip-on-non-zero branch is now unreachable on the production handler path because `_validate_producer_draft_present` rejects an absent plan upstream. Documents the precedence invariant so a future refactor that re-orders the guards cannot silently re-introduce the #3016 bug. 3. Fill in test-parity gaps with TestPlannerRoleAlignmentValidation: - `test_accepts_when_plan_draft_present` (locks the symmetry with refine). - `test_skips_when_pipeline_lookup_fails` (covers StateStoreError branch). - `test_skips_when_branch_verified_is_none` (regression guard for the primary review concern — asserts git is not even invoked). 4. Stale-comment drive-by: `signals.py:1004` said '16k-line routes.pipelines' and the new function said '~21k-line'. The file is now 24,348 lines; both updated to '~24k-line'.
|
Thanks for the thorough review. All four items addressed in Per-item disposition1. 2. Planner alignment's now-unreachable silent-skip — 3. Test-parity gaps —
All 11 cases in 4. Stale — Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-reviewed the delta between 1ba8611 and b89fe0f. All four items from the previous review are addressed correctly and surgically. Walked the threading through every edge case in handle_consensus_propose_signal; the tri-state lands at _validate_producer_draft_present consistently.
Per-item verification
1. branch_verified tri-state threading — signals.py:1108, 1165-1170, 1404, 1411, 1439, 1459, 1473. The validator short-circuits on branch_verified is None before the state-store load (signals.py:1169-1170 → 1172-1176), so a _verify_commit_on_branch glitch doesn't even attempt a second load_pipeline. Default of True keeps direct unit-test callers strict. The outer except Exception block at signals.py:1429-1439 correctly degrades to None for raises from _verify_commit_on_branch, load_pipeline, or resolve_worktree_path — all three are "orchestrator-side glitch, not producer fault" surfaces and skipping the presence check matches the existing "Could not verify commit on branch (non-blocking)" warning posture. Traced six edge cases (missing SHA, empty branch, True/False/None from verify, load_pipeline raises) — all produce the right behavior.
2. Planner alignment comment — signals.py:1036-1046. The comment explicitly names _validate_producer_draft_present as the upstream guard, explains why the two validators respond differently to the same returncode != 0 signal, and flags the refactor-risk that would re-introduce #3016. This is exactly the protection the invariant needed.
3. Test parity — three new tests at test_pipeline_prompts.py:2548-2617. test_accepts_when_plan_draft_present locks the symmetry. test_skips_when_pipeline_lookup_fails uses StateValidationError which is a StateStoreError subclass (state_store.py:73) — valid; covers the previously-uncovered except StateStoreError: return branch. test_skips_when_branch_verified_is_none is the right shape: mock_run.assert_not_called() confirms the validator short-circuits before git access, which is what the threading is supposed to guarantee. All 11 cases in TestProducerDraftPresentValidation pass locally; the 7 TestPlannerRoleAlignmentValidation cases still pass after the comment update.
4. Stale 16k-line comments — both signals.py:1004 and signals.py:1181 now say ~24k-line, aligned with each other and with wc -l reality. Drive-by fixed.
What I verified
- The threading edge cases above (six traced).
_validate_planner_role_alignmentis intentionally not threaded withbranch_verified— its pre-existingreturncode != 0 → silent skipalready absorbs the same orchestrator-glitch scenario without false-rejecting, so there's no regression here, just an asymmetry the new comment correctly documents.- The integration test
test_refiner_proposal_rejected_when_analysis_missing_does_not_mutate_tracker(test_pipeline_prompts.py:2653-2701) still passes with its three-call subprocess sequence (fetch=0, contains=origin/branch, show=128). The branch-verified path through the handler is still True here, so the strict check fires as intended. - All CI checks green at HEAD.
Non-blocking
1. No end-to-end test for the branch_verified=None threading path
The new test_skips_when_branch_verified_is_none exercises the validator directly with branch_verified=None. There's no integration-level test that mocks _verify_commit_on_branch to return None (via a fetch-failure subprocess sequence: returncode=1 on call 1) and asserts the producer's proposal reaches tracker.handle_propose instead of being 400'd. The threading is small and obvious enough that I don't think this is required for merge, but it would lock the wiring in the same way test_refiner_proposal_rejected_when_analysis_missing_does_not_mutate_tracker locks the rejection wiring. Worth considering for a follow-up.
2. Docstring redundancy at signals.py:1135-1141
The pre-existing "Graceful degradation" paragraph lists four skip conditions (no SHA, no branch, no path, infrastructure error on git show). The new branch_verified paragraph at 1148-1159 adds the fifth condition. The first paragraph's phrase "raises only when it can positively confirm the draft is absent (or empty) at a resolved, branch-verified commit" technically already excludes the new None case, but a reader scanning top-down might miss the dependency on the later paragraph. A single sentence on the first paragraph ("and when branch verification was inconclusive — see below") would tie them together. Minor wording nit.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
… docstring tie-in Both items from the second non-blocking review. 1. test_refiner_proposal_accepted_when_branch_verification_inconclusive: end-to-end test mocking the fetch glitch (returncode=1 on git fetch), asserting the proposal reaches tracker.handle_propose with a 200 instead of being false-rejected by the presence guard. Locks the tri-state threading at the integration level the way the existing refiner-rejection test locks the rejection wiring. 2. Tie the 'Graceful degradation' paragraph to the later branch_verified paragraph so a top-down reader sees all five skip conditions in one place.
|
Thanks for the re-review. Both non-blocking items addressed in Per-item disposition1. No end-to-end test for the 2. Docstring redundancy at — Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-reviewed the delta between b89fe0f and 8900d12 (the single new commit). Both non-blocking items from my second review are addressed correctly and surgically. Approving.
Per-item verification
1. End-to-end test for branch_verified=None threading — test_pipeline_prompts.py:2653-2735. The test models the right failure mode (fetch returncode=1, stderr fatal: unable to access remote: connection reset) and asserts the three guarantees that matter:
status_code == 200— the proposal isn't 400'd by the presence validator.mock_tracker.handle_propose.assert_called_once()— the proposal made it past both validators into the tracker.mock_run.call_count == 1— confirms the validator short-circuits before callinggit show. This is the threading guarantee under test: if thebranch_verifiedkwarg were dropped from the call site atsignals.py:1461, the validator would default toTrue, rungit showas call #2, and theside_effectlist (length 1) would raiseStopIteration→ the outerexceptreturns 500. So the test does fail closed if the wiring breaks.
The two extra mocks beyond _patched_store (the explicit mock_pipeline.current_phase.value = "refine" and the message_store.get_message_store patch) are both required and the inline comment correctly justifies them — the rejection-path end-to-end test reaches the 400 before message emission, so it doesn't need them. Mirror with test_refiner_proposal_rejected_when_analysis_missing_does_not_mutate_tracker (3-call subprocess sequence vs. 1-call, accept vs. reject) is exactly the symmetric pair the wiring needs.
2. Docstring tie-in — signals.py:1135-1143. The "Graceful degradation" paragraph now lists all five skip conditions inline (no commit SHA, no branch, no draft path, git show infrastructure error, branch_verified is None — see below), with the explicit forward-pointer to the dedicated branch_verified paragraph at signals.py:1150-1161. Top-down readers no longer have to scan ahead to discover the fifth condition. Minor wording nit resolved.
What I verified
- Traced the success path through
handle_consensus_propose_signalfor the new test:_verify_commit_on_branchreturnsNone(fetch returncode=1 →signals.py:378-385);branch_verifiedlands at_validate_producer_draft_presentasNone; validator returns at line 1172 before any state-store load or git call;handle_proposeis invoked;result = {"version": 1}is dict-safe for the empty.get("stale_reviewers", [])/.get("newly_ready", [])/.get("status")lookups downstream;_resolve_pipeline_phasereadsmock_pipeline.current_phase.value == "refine"(Pydantic-string-valid);make_success_responsereturns 200. - The single subprocess call in the test (fetch only) is the correct minimum — if the validator were called with default
branch_verified=True, the unprovided secondsubprocess.runcall wouldStopIterationand the assertionmock_run.call_count == 1would fail. - No symmetric test for the
task_plannerpath through_validate_producer_draft_presentwithbranch_verified=None, but the threading is identical (same call site, same kwarg, same shared validator code), and the previous review explicitly didn't ask for it. Not worth adding. - All CI checks green at HEAD (
8900d12).
Non-blocking
None. This is a clean follow-up to the second review's non-blocking notes.
— Authored by egg
|
egg review completed. View run logs 6 previous review(s) hidden. |
…ssing yaml-tasks (#3026) (#3028) * fix(orchestrator): consolidate plan propose-time validation, catch missing yaml-tasks (#3026) A plan draft that is complete in prose but omits its machine-readable `# yaml-tasks` appendix passed BRC plan consensus and phase completion, then failed the whole pipeline ~40 min later at `populate_contract` (`parse_failed`, empty `contract.slices`) — bypassing the plan HITL gate and raising an expensive recovery decision instead of a cheap NACK→re-propose. Root cause was stacked propose-time guards: for a `task_planner` proposal the handler ran *two* validators that each `git show`-ed the same plan draft at the same commit. `_validate_producer_draft_present` checked existence only (the prose draft exists, so it passed), and `_validate_planner_role_alignment` already called the populator's `parse_plan` but silently `return`ed on `not parsed.success` — exactly the missing-appendix case — throwing the result away. Consolidate the two into a single `_validate_plan_proposal` that does one `git show` + one `parse_plan` and asserts, in order: presence (#3016), parseability into ≥1 slice (#3026 — reusing the same parser the contract populator runs, mirroring `_populate_result_is_empty_contract` so propose-time and populate cannot diverge), and role↔files alignment (#2527). `_validate_producer_draft_present` stays as the refine presence guard (analysis drafts have no parseable appendix, so existence-only is correct there). Removes the duplicate `git show` and the "two guards with opposite responses to the same returncode" footgun the #3019 comment warned about. * address review: drop dead slice-count check, wrap to_contract_slices, soften docstring - (1) Remove if not slices: branch — parse_plan guarantees success=True ⇒ ≥1 phase ⇒ ≥1 slice via per-phase placeholder-task injection, so the check was unreachable. Relabel the docstring item (2) from "Parseability / ≥1 slice" to "Parseability" to match. - (2) Wrap parsed.to_contract_slices() in try/except, restoring the graceful-skip posture the pre-consolidation try/except wrapped it in. A future Pydantic field tightening on Task / Slice could otherwise surface a 500 instead of the previous silent-skip — asymmetric with how parse_plan and validate_task_role_alignment are wrapped on either side. - (3) Soften the docstring's cannot diverge claim to cannot diverge on parse failures. _populate_result_is_empty_contract also rejects on FOREST_VIOLATION / UNEXPECTED_EXCEPTION etc., which this propose-time guard does not mirror by design (only the parse_failed / empty_result outcomes #3026 names). Review feedback from egg-reviewer on PR #3028. --------- Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
What
Make the refine/plan phase-gate input deterministic: validate at
consensus_proposethat the producer's canonical phase draft exists at the path every downstream reader uses, and reject (400) when it is missing — so a producer that commits its draft off-path can no longer silently complete the phase and leave the operator gate reporting "No analysis draft was found on the work branch."Closes #3016.
Why
A refiner committed its analysis to
.egg-state/agent-outputs/refiner-refine.md— a path no orchestrator/sandbox code reads. The canonical path everything reads (gate_read_phase_draft,pipeline.analysisrepopulation, contract population) is.egg-state/drafts/{issue}-analysis.md. Result: BRC consensus CONFIRMed, the phase completed, and only then did the gate come up empty. This is not caught by the read-side hardening in #2992 / #2723 — a draft at a path nobody reads is invisible togit show HEAD:.egg-state/drafts/…too.refine/planare always concurrent, so every producer issuesconsensus_propose— a universal choke point. The orchestrator already validates proposals there (_validate_tester_check_coverage,_validate_planner_role_alignment) beforehandle_proposerecords them;_validate_planner_role_alignmenteven alreadygit shows the plan draft at the proposed commit — but treated a missing draft as "nothing to validate" and passed. This PR closes that hole and adds the equivalent guard for refine (which had none).How
_validate_producer_draft_present(phase, …)inorchestrator/routes/signals.py: runsgit show {commit}:{_get_draft_path(phase, …)}against the orchestrator worktree at the proposed commit; raisesValueError(→ 400) when the draft is absent or empty, with an actionable message naming the canonical path. Existence-only — format/template conformance is deliberately left to reviewers and the gate, to avoid false-rejecting a legitimate-but-differently-structured draft (see Genericize the SDLC pipeline into a declarative, config-driven workflow (custom phases + agents + per-phase slicing) #3017).handle_consensus_propose_signalbeforehandle_propose, forrefiner(phaserefine) andtask_planner(phaseplan, alongside the existing role-alignment check). task_planner is the canonical plan-draft owner — architect/risk_analyst write agent-outputs JSON;_synthesize_plan_draft's docstring confirms task_planner writesdrafts/{id}-plan.mddirectly._verify_commit_on_branch(which fetches the commit) runs first, so a non-zerogit showhere reliably means "path absent at commit", not "commit unknown".Scope notes
brc_propose(issue feat(orchestrator): validate producer's phase draft at the canonical path on propose (deterministic refine/plan gate input) #3016 item 3) is a latency optimization, not correctness — the agent already receives the orchestrator's actionable 400 (surfaced bybrc_propose'sGatewayError) and re-proposes. Deferred to keep this focused.Test
TestProducerDraftPresentValidation(8 cases): missing-SHA skip, present / absent / empty draft, plan-vs-analysis message wording, infra-error graceful skip, no-branch skip, and an end-to-endhandle_consensus_propose_signalrefiner-reject asserting the tracker is not mutated.git showcall now in its path.make test(changeset-aware) +make lintgreen.