fix(orchestrator): unwrap choice envelope in hard-reset recovery HITL dispatch (#2978) - #2981
Conversation
… dispatch (#2978) The SDLC HITL CLI resolves a choice decision by sending {"action": "select", "selected": "<option>"} (sandbox/egg_lib/sdlc_hitl.py), which resolve_decision serializes into decision.resolution. But _handle_hard_reset_recovery_resolution did a bare-string compare against "Continue with post-reset state" / "Abort pipeline" and cross-checked the raw resolution against valid_options. The JSON envelope matched neither, so every operator selection routed to the unrecognized-option path (OVERSEER_ALERT), no dispatch ran, and the pipeline stayed wedged in failed_pending_hitl — the operator had to fall back to cancel_task. Add _normalize_choice_resolution to unwrap the select envelope to its bare option label before both the valid_options cross-check and the Continue/Abort dispatch, mirroring the phase-gate path that already parses this envelope. Bare-string and non-select resolutions pass through unchanged. Also reconcile orchestrator/README.md's stale pre-#2797 worktree-sync contract with the current rebase-then-hard-reset-recovery behavior, and note the non-destructive redesign tracked in #2979. Tests drive the real JSON envelope through the dispatch helper (Continue and Abort, including the Abort-only doubly-failed options list) plus direct unit coverage of the normalizer. The prior tests only fed already-extracted bare strings, which is why the bug shipped green.
There was a problem hiding this comment.
Review
The targeted fix for _handle_hard_reset_recovery_resolution is correct and well-tested. The new _normalize_choice_resolution helper correctly unwraps the {"action": "select", "selected": ...} envelope, the integration in the dispatch hook puts the normalization before the valid_options cross-check (right ordering — the doubly-failed branch test confirms this still routes a Continue envelope to the unknown-resolution path when only Abort pipeline is allowed), and the edge-case handling (bare-string passthrough, malformed JSON, non-select action shapes, empty input) matches what callers need. The README reconciliation accurately describes the post-#2797 rebase-then-hard-reset behavior and correctly forward-references #2979 for the non-destructive redesign. CI is green and the integration tests cover the production code path through the dispatch helper rather than direct calls on already-unwrapped strings — exactly the gap the PR description correctly identifies in the prior tests.
Blocking — parallel bugs in the same file left unfixed
The PR introduces _normalize_choice_resolution as a module-level helper but applies it to only one of the four dispatch hooks reached from resolve_decision() (orchestrator/routes/decisions.py:874) that have the identical bug pattern. Every other branch still does a bare-string compare against decision.resolution, which — as the PR itself documents — is the JSON envelope, not the bare option label, whenever the operator resolves through the standard SDLC HITL CLI (every choice decision routes through _handle_choice in sandbox/egg_lib/sdlc_hitl.py:709-718, which wraps the selection in {"action": "select", "selected": "..."}).
Concretely still broken after this PR:
orchestrator/routes/decisions.py:955—if decision.resolution == "Restart agent":against the overseer-created agent-failure HITL (orchestrator/overseer/monitor.py:1924, options["Restart agent", "Continue monitoring", "Cancel pipeline"]). Operator selects "Restart agent" viaprovide_input→ envelope → bare-string compare fails →_handle_restart_agentnever runs → the failed container is not respawned, but the decision is marked RESOLVED. Same wedged-pipeline shape as #2978.orchestrator/routes/decisions.py:973—if decision.resolution == "Continue without"against the concurrent-executor failed-reviewer HITL (orchestrator/concurrent_executor.py:596, options["Retry (respawn agent)", "Abort phase", "Continue without"]). Same envelope, same dropped dispatch →tracker.excuse_reviewer()is never called → BRC consensus stays blocked on the failed reviewer indefinitely. This is exactly the recovery path operators reach for after an agent dies, so the silent-no-op leaves the pipeline stuck at the worst time.orchestrator/routes/decisions.py:390-401(_handle_conditional_ack_gate) — the 3-way conditional-ACK HITL (orchestrator/routes/phases.py:860-864,decision_type="choice") comparesresolutiondirectly againstCONDITIONAL_ACK_APPROVE/CONDITIONAL_ACK_REJECT/CONDITIONAL_ACK_ADDRESS. Same envelope → all three branches fall through to the "unrecognized option"logger.infoand all three downstream effects are skipped (_persist_deferred_actionsdoesn't write the obligations tocontract.pr.deferred_actions, the producer is never NACK'd back, the ACK isn't invalidated). The PR body becomes incoherent and consensus state silently desyncs from operator intent.
These three are not "tracked separately under #2979" — #2979 is scoped to the destructive-recovery redesign, and #2978's body explicitly identifies the envelope/dispatch fragility as the kind of bug that needs fixing at every site (the "two-wave HITL gap" reference is the symptom across sites, not a separate non-overlapping issue). The fix is trivial — either call _normalize_choice_resolution from the three other sites, or (cleaner) move the normalization to resolve_decision() itself, e.g.:
decision = queue.resolve_decision(decision_id, resolution)
# Normalize choice-envelope resolutions once at the dispatch boundary so
# every helper below sees the bare option label instead of the JSON envelope.
dispatch_resolution = _normalize_choice_resolution(decision.resolution or "")
...
if dispatch_resolution == "Restart agent":
_handle_restart_agent(pipeline_id, decision.question)
...That preserves decision.resolution as the raw envelope on disk (audit trail intact) but fixes all four dispatch hooks at once. The PR is in the right file, has the right helper, and has the test scaffolding to extend. Per the review rules — "Pre-existing issues are still blocking: If a PR modifies code that already has broken or inconsistent behavior, request changes to fix it" — please fold these three parallel bugs into this PR. They are the same bug, in the same function, fixable with the same helper this PR introduces.
Non-blocking observations
- Duplicated option strings.
_HARD_RESET_RECOVERY_CONTINUE/_HARD_RESET_RECOVERY_ABORTare defined as constants atorchestrator/routes/pipelines.py:14327-14328, butorchestrator/routes/decisions.py:254,265hardcode the same strings inline. Pre-existing inconsistency that drifts the moment one side renames an option; importing the constants from the producer side would prevent that. Worth fixing while you're here but won't block. - Logged
resolutionis now the normalized form. Behavioral change — previously thelogger.warning/ OVERSEER_ALERT body atorchestrator/routes/decisions.py:276,316would have included the raw envelope JSON; now they include the unwrapped bare label (or the original on non-select shapes). Diagnostically cleaner, but worth a sentence in the docstring noting that the audit trail (decision.resolutionon disk) still carries the envelope; logs do not. - Coverage micro-gap. No envelope-form test for the doubly-failed
Continue-against-["Abort pipeline"]cross-check rejection — the existing bare-string test (test_continue_blocked_when_only_abort_allowedin the same file) covers the logic post-normalization, so the call paths are exercised, but a single envelope-form assertion mirroring it would prevent future drift if normalization is ever moved/removed. Not blocking on its own.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
… dispatch hooks (#2978) The #2978 review pointed out three parallel bugs in resolve_decision with the same shape as the hard-reset recovery one already fixed: Restart agent (#1428), Continue without (concurrent_executor failed reviewer), and the conditional-ACK 3-way gate (#2004). Each compared decision.resolution against bare option labels, so any operator selection routed through the SDLC HITL CLI envelope {"action": "select", "selected": "<option>"} fell into the no-op unrecognized-option branch while the decision was still marked RESOLVED — wedging the pipeline. Fix: compute dispatch_resolution = _normalize_choice_resolution(...) once at the dispatch boundary in resolve_decision() and route the four hooks through it. decision.resolution on disk, the DECISION_RESOLVED event payload, and the API response keep the raw envelope so the audit trail is unchanged; only the in-process value handed to the dispatch helpers (and the log lines they emit) is the bare label. Also from the review's non-blocking observations: - routes/decisions.py now imports _HARD_RESET_RECOVERY_CONTINUE / _HARD_RESET_RECOVERY_ABORT from routes.pipelines instead of hardcoding the same strings inline, so a rename on the producer side can't silently desync the dispatch. - The unknown-resolution OVERSEER_ALERT body interpolates the same constants in the "expected one of …" sentence. - _normalize_choice_resolution's docstring spells out that the persisted resolution / event / response keep the envelope; only dispatch-side values are normalized. Tests: - test_decisions_routes.py — envelope-form Restart agent triggers _handle_restart_agent and the persisted resolution stays the raw envelope; envelope-form Continue without on failed_role:<role> drives excuse_reviewer. - test_conditional_ack_hitl_gate.py — envelope-form Approve / Reject / Address each route to _persist_deferred_actions / _force_nack_conditional_edges / _invalidate_conditional_acks. - test_hard_reset_recovery.py — envelope-form Continue against the doubly-failed Abort-only options list is still rejected by the valid_options cross-check (mirror of test_continue_rejected_when_not_in_valid_options). Refs #2978.
|
Thanks for the review — folded all three parallel-bug fixes into this PR using the cleaner dispatch-boundary normalization you suggested, plus the two non-blocking observations. Disposition
Implementation noteTook the cleaner option from the review: normalize once in
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review (re-review of 388f29b → 1c6633e)
All three blocking issues from my prior review are correctly addressed via the cleaner dispatch-boundary normalization. The implementation routes all four hooks through dispatch_resolution = _normalize_choice_resolution(decision.resolution or "") at orchestrator/routes/decisions.py:952, and the four call sites (Restart agent at :976, _handle_conditional_ack_gate(..., dispatch_resolution, ...) at :983-988, Continue without at :994, _handle_hard_reset_recovery_resolution(..., dispatch_resolution, ...) at :1027-1032) all consume the unwrapped label. The persisted decision.resolution, the DECISION_RESOLVED event payload (:960), and the API response body (:1040) intentionally still carry the raw envelope, so the audit trail records exactly what the operator sent — this is exercised by test_resolution_persisted_as_envelope_for_audit_trail. The defense-in-depth _normalize_choice_resolution call inside _handle_hard_reset_recovery_resolution (:237) is idempotent and protects future direct callers; the existing direct-call tests still flow through it.
Test coverage matches each fix:
test_restart_agent_envelope_triggers_restartandtest_continue_without_envelope_calls_excuse_reviewercover blocking #1 and #2 at the route level.test_approve_envelope_persists_deferred_actions,test_reject_envelope_force_nacks_edges,test_address_envelope_invalidates_ackscover blocking #3 (the three conditional-ACK branches) through the production route.test_select_continue_envelope_blocked_when_only_abort_allowedadds the previously-missing envelope-form mirror of the doubly-failed cross-check rejection, including a check that the OVERSEER_ALERT body reports the unwrapped label rather than the raw envelope JSON.
I ran the touched test modules locally to confirm:
pytest orchestrator/tests/test_hard_reset_recovery.py— 36 passedpytest orchestrator/tests/test_decisions_routes.py::TestChoiceEnvelopeDispatchNormalization— 3 passedpytest orchestrator/tests/test_conditional_ack_hitl_gate.py— 31 passed
The non-blocking observations from the prior review are also handled: _HARD_RESET_RECOVERY_CONTINUE / _HARD_RESET_RECOVERY_ABORT are imported from routes.pipelines (no more drift-prone literal duplication at :264/:275 and in the OVERSEER_ALERT "expected one of …" sentence at :310-311), and the normalizer docstring at :166-172 spells out the audit-trail invariant clearly.
The except json.JSONDecodeError, TypeError: syntax at :178 is the PEP 758 parenthesis-free form valid only on Python 3.14+, which matches pyproject.toml's requires-python = ">=3.14" and the existing pattern at routes/pipelines.py:20141 / :20512. Consistent with the codebase.
Non-blocking
- Inconsistent defense-in-depth across dispatch helpers.
_handle_hard_reset_recovery_resolutionkeeps an internal_normalize_choice_resolutioncall (:237) so direct callers stay safe, but_handle_conditional_ack_gate(:343) and_handle_restart_agent(_handle_restart_agentdoesn't take a resolution at all, so it's fine) rely solely on the dispatch boundary. A future direct caller of_handle_conditional_ack_gatethat bypassesresolve_decisionwould silently miss the envelope unwrap and fall through to the "unrecognized option"logger.infoat :408. Trivial follow-up: either move the same defensive call into_handle_conditional_ack_gate, or drop the redundant one from_handle_hard_reset_recovery_resolutionand rely on the dispatch boundary uniformly. Not blocking — both helpers are only reached throughresolve_decisiontoday. TypeErrorclause in the normalizer._normalize_choice_resolution'sexcept (json.JSONDecodeError, TypeError):(:178) is defensively catching a path that's effectively unreachable: the function'sresolution: strsignature plus theif not resolution:early return at :175 meanjson.loadsonly sees a non-emptystr, which never raisesTypeError. Harmless, just noise.
LGTM.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- Add defense-in-depth _normalize_choice_resolution call to _handle_conditional_ack_gate for symmetry with _handle_hard_reset_recovery_resolution. Both helpers are now safe against future direct callers bypassing the resolve_decision dispatch-boundary normalization; idempotent on already-unwrapped strings. - Drop unreachable TypeError from _normalize_choice_resolution's except clause. The function's resolution: str signature plus the empty-string early return mean json.loads only sees a non-empty str, which never raises TypeError.
|
Thanks for the LGTM — folded both non-blocking observations into this PR as in-PR fixes (commit Disposition
Verification
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review (re-review of 1c6633e → 5f2a675)
Both non-blocking observations from the prior round are correctly handled in the new commit.
Defense-in-depth symmetry (_handle_conditional_ack_gate). The new resolution = _normalize_choice_resolution(resolution) at orchestrator/routes/decisions.py:390 mirrors the existing call inside _handle_hard_reset_recovery_resolution at :237. Both helpers now unwrap the {"action": "select", "selected": ...} envelope before any dispatch comparison runs, so a future direct caller that bypasses resolve_decision's dispatch-boundary normalization at :959 will still see the bare option label at the three CONDITIONAL_ACK_* compares (:408-413). Idempotent on already-unwrapped strings — the production route path is unaffected because dispatch_resolution is pre-normalized and json.loads("Approve and accept obligations") raises JSONDecodeError, returning the input unchanged. Placement before the CONDITIONAL_ACK_GATE_MARKER startswith check at :392 is harmless (pure function, no side effects). The inline comment at :385-389 correctly cross-references _handle_hard_reset_recovery_resolution so a future reader sees the symmetry.
Unreachable TypeError dropped. _normalize_choice_resolution at :178 now reads except json.JSONDecodeError: only. With the resolution: str signature plus the if not resolution: early return at :174-175, json.loads only ever sees a non-empty str, which can raise JSONDecodeError but not TypeError. Removing the unreachable clause is a no-op for behavior and cleaner.
Verified locally:
pytest orchestrator/tests/test_conditional_ack_hitl_gate.py -k envelope— 3 passed (the three route-level envelope tests still flow through the dispatch-boundary normalization at:959unchanged).pytest orchestrator/tests/test_decisions_routes.py -k "envelope or normalize"— 3 passed.python3 -c "import ast; ast.parse(open('orchestrator/routes/decisions.py').read())"— clean parse on 3.14 (consistent with thepyproject.tomlrequires-python = ">=3.14"and the PEP 758 parenthesis-freeexceptform used elsewhere in the file, e.g.:459).
The audit-trail invariant (persisted decision.resolution, DECISION_RESOLVED payload at :967, API response resolution at :1047) still carries the raw envelope; only the in-process dispatch_resolution and the route-level helpers' local resolution variable are the unwrapped form. Docstring at :166-172 accurately describes this.
No new issues. LGTM.
— Authored by egg
|
egg review completed. View run logs 8 previous review(s) hidden. |
Summary
Slice A of #2978: the hard-reset recovery HITL ack was undispatchable through
the standard SDLC flow, leaving pipelines wedged in
failed_pending_hitl.The SDLC HITL CLI resolves a
choicedecision by sending{"action": "select", "selected": "<option>"}(sandbox/egg_lib/sdlc_hitl.py:716),which
resolve_decisionserializes intodecision.resolution. But_handle_hard_reset_recovery_resolution(orchestrator/routes/decisions.py) did abare-string compare against
"Continue with post-reset state"/"Abort pipeline"and cross-checked the raw resolution against
valid_options. The JSON envelopematched neither →
OVERSEER_ALERT: hard-reset-recovery-unknown-resolution, decisionmarked RESOLVED, no dispatch ran, pipeline stuck (live repro on pipeline-8cf1f000:
the operator had to fall back to
cancel_task).The phase-gate path already unwraps this envelope (
routes/pipelines.py:18573,22783); the hard-reset dispatch was the one place that didn't.Changes
routes/decisions.py— add_normalize_choice_resolution, which unwraps the{"action": "select", "selected": ...}envelope to its bare option label. Call itat the top of
_handle_hard_reset_recovery_resolution, before thevalid_optionscross-check and the Continue/Abort compares. Bare-string (legacy /direct-API) and any non-
selectshape pass through unchanged.README.md— reconcile the stale pre-Fix #2792: auto-recover from sync divergence with HITL ack #2797 worktree-sync contract ("fast-forwardmerge … leaves the worktree unchanged") with the current
rebase-then-hard-reset-recovery behavior, and point at the [orchestrator/sync] Non-destructive divergence reconcile + prevent self-inflicted plan-sync divergence (split from #2978) #2979 redesign.
tests/test_hard_reset_recovery.py— drive the real JSON envelope through thedispatch helper (Continue + Abort, including the Abort-only doubly-failed
valid_options), plus direct unit coverage of the normalizer. The prior dispatchtests only fed already-extracted bare strings, which is why the bug shipped green.
Out of scope (tracked in #2979)
The destructive hard-reset reconcile itself — discarding committed work to a backup
ref and marking the pipeline FAILED post-consensus — and the self-inflicted plan-sync
divergence at the source. This PR only makes the existing recovery ack reachable.
Test plan
.venv/bin/pytest orchestrator/tests/test_hard_reset_recovery.py— 36 passed(5 new envelope/normalizer tests).
ruff check+ruff format --checkclean; pre-commit hooks pass.Closes #2978.