fix(hitl): dedupe contract cq-N decisions + surface them in get_status - #3377
Conversation
…s (#3374) Contract-resident cq-N HITL decisions (register_open_question / impasse escalation) had two defects: 1. Duplicate registration — the same question registered by successive phases minted distinct cq-N ids with no dedupe, so answering one left an identical sibling outstanding. 2. Never surfaced — cq-N live on the contract, not the orchestrator queue, so get_status.pending_decisions never showed them. They only entered the queue via the post-gate bridge (current phase only), so an operator driving via get_status/wait-status got zero signal that HITL questions were pending until a future phase gate. Fixes: - Dedupe at registration: new egg_contracts.decisions.find_duplicate_open_question (normalized question + phase) adopted idempotently by register_open_question and the impasse-escalation router instead of minting a new cq-N. - Surface in status: get_status now exposes unresolved, not-yet-bridged cq-N in a sibling pending_contract_decisions field (kept distinct from the queue so the two-wave resolve flow is unaffected; already-bridged ids filtered out). - Gate-approval guard: the phase_gate resolve response includes outstanding_contract_decisions (later-phase unresolved cq-N the approval leaves open). Docs (hitl-decisions.md, sdlc SKILL.md) and tests updated.
There was a problem hiding this comment.
Review: dedupe contract cq-N decisions + surface in get_status
I traced all three mechanisms end-to-end and reviewed every changed file. No blocking issues — the feature works in its real execution path:
find_duplicate_open_questioncorrectly handles bothDecisionpydantic instances (impasse router) and plain JSON dicts (sandbox handler), and normalizes phase to its string value on both sides so enum/str/None compare consistently.- The bridged-filter regex
Open contract question (cq-\d+),matches the exact context string the real bridge writes (routes/pipelines.py:22572), andpipeline.model_dump(mode="json")carries thecontextfield, so the double-list suppression actually fires. task_idis URL-quoted before_pending_contract_decisionsis reached (_status.py:81), and the/api/v1/contracts/{id}?pipeline_id={id}shape matches_handle_get_contract/_snapshot._outstanding_contract_hitl's "skip current-phase and phase-less" logic mirrors the bridge's promotion filter (d.phase is None or d.phase == phase_value,pipelines.py:22524), so it flags exactly the later-phase questions the bridge leaves behind.- All five test additions exercise the production helpers (real
route_impasses, realsave_contract/load_contract, realregister_open_questionagainst a mocked gateway) rather than hand-built fixtures or self-seeded goldens.
Non-blocking observations
1. Dedup is phase-scoped, but the PR description says "successive phases." find_duplicate_open_question requires d_phase == target_phase. In the agent path, decision_phase = phase or current_phase, so an identical question registered in refine (phase=refine) and re-asked in plan (phase=plan) will not dedupe — a fresh cq-N is minted. The same-phase re-run case (the impasse router, a re-spawned agent within one phase) is covered, and same-phase dedup is internally consistent and documented. But the Problem statement frames the defect as "registered by successive phases," which this does not address. Please confirm the originating repro was same-phase (or a re-run), not a genuine cross-phase re-ask — if it was cross-phase, the headline scenario isn't fixed. Worth a one-line clarification in the description either way, since docs/hitl-decisions.md says "A later phase ... adopts the existing cq-N" which overstates the actual same-phase guarantee.
2. Re-registration with changed options silently adopts the stored options. On a dedup hit, register_open_question returns the existing decision dict verbatim (sdlc.py:111-117); the freshly-computed opt_objs from the new request are discarded. If an agent re-asks the same normalized question text but with an expanded/different option set, the operator only ever sees the original options and the new ones vanish with no signal. Narrow, but the question text being equal doesn't guarantee the intended option set is. Consider at least logging when a dedup hit has a differing option set.
3. Truncation limits are inconsistent across the two new surfaces. _pending_contract_decisions truncates questions at _CONTRACT_QUESTION_MAX_CHARS = 4_000 (_status.py), while _outstanding_contract_hitl truncates at a hardcoded 2_000 (_resolve.py:330). Same cq-N question rendered two different lengths depending on the surface. Pick one constant.
4. Surfaced entry shape is broader than documented. _pending_contract_decisions emits type and note keys in addition to the documented id/question/phase/options/scope. Harmless, but docs/hitl-decisions.md and SKILL.md enumerate only five fields — either document the extras or drop them.
5. get_status now issues an extra contract fetch on every call. The surfacing query runs unconditionally (it must fetch to know whether anything is pending), adding one HTTP round-trip per get_status/wait-status poll. It's best-effort and fails open, so not a correctness concern, but wait-status is a hot path — confirm the added per-poll latency is acceptable, or consider gating the fetch on a cheap signal.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
… phase-scope docs - Share a single CONTRACT_QUESTION_MAX_CHARS (4000) cap via egg_contracts.decisions.truncate_question so the get_status surfacing and the phase_gate guard render the same cq-N at one consistent length (was 4000 vs a hardcoded 2000). - Log a warning when a dedup hit's re-registration carries a different option set than the stored decision, so the silently-discarded options are no longer invisible (register_open_question + impasse router). - Correct docs/SKILL wording: dedup is phase-scoped (keyed on normalized question + phase); a genuine cross-phase re-ask mints a fresh cq-N by design. - Document the surfaced entry's type/note fields in hitl-decisions.md and the SDLC SKILL.
|
Thanks for the end-to-end trace. All five non-blocking observations addressed in commit a3f955f. 1. Dedup is phase-scoped, but the description said "successive phases." — 2. Re-registration with changed options silently adopts the stored options. — 3. Truncation limits inconsistent across the two surfaces (4000 vs 2000). — 4. Surfaced entry shape broader than documented ( 5.
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review: dedupe contract cq-N decisions + surface in get_status
Re-reviewed the delta against my prior approve-with-suggestions at 067dfeff. The shallow PR ref collapses to a single squashed commit (a3f955f), so I reviewed the full authoritative gh pr diff (17 files) and traced each of my five earlier observations to its fix.
All five prior observations are properly resolved — not superficially patched:
-
Phase-scoped dedup overstatement (#1). Fixed in docs.
docs/hitl-decisions.mdandskills/sdlc/SKILL.mdnow state the dedup is keyed on(normalized question, phase), that it covers the same-phase re-run / re-escalation repro, and that a genuine cross-phase re-ask mints a freshcq-Nby design (agent path defaultsphasetocurrent_phase). This matches the actual behavior offind_duplicate_open_question(thed_phase != target_phaseshort-circuit) — verified. -
Silent option-set loss on dedup hit (#2). Both producers now log when the re-registration's option labels differ from the stored set.
sdlc.py:120-137(_logger.warning, dict-sideo.get("label")) andimpasse_routing.py:537-549(logger.info(..., options_differ=...), attr-sidegetattr(o, "label", None)). The label-extraction style correctly differs per side (dict vsDecisionOptioninstance) and is comparison-only, so no type-mismatch risk. The stored set remaining authoritative is the right call (merging mid-flight would mutate a decision the operator may already be viewing). -
Inconsistent truncation (4000 vs 2000) (#3). Fixed at the root:
CONTRACT_QUESTION_MAX_CHARS = 4000+truncate_question()hoisted intoegg_contracts.decisions, exported, and called by both_pending_contract_decisions(_status.py:209) and_outstanding_contract_hitl(_resolve.py:360). Samecq-Nnow renders at one length on both surfaces. -
Undocumented
type/notekeys (#4). Documented in bothhitl-decisions.mdandSKILL.mdrather than dropped — reasonable, sincetype:"hitl"mirrors the queue shape andnotepoints at theprovide_inputflow. -
Extra per-poll contract fetch (#5). Author declined with sound reasoning — the surfacing must read the contract to know whether unbridged
cq-Nexist, no cheaper in-snapshot gate exists, and the fetch is best-effort/fails-open. The accompanying test (test_running_agents_no_fallback_when_persisted_present) was correctly updated to add the third mocked response and assert on the absence of a/statuscall rather than a raw count. Acceptable as a non-blocking tradeoff.
End-to-end correctness re-verified on the new code:
find_duplicate_open_questionhandlesDecisioninstances and JSON dicts via_field, normalizestype/phasethrough.value, and empty-question-guards (if not target: return None). Phase serialization is consistent across paths: the impasse router storesPipelinePhase,save_contractwrites.value, the agent path reads the string back, and the comparator collapses all three to a string — so arefine/planenum, its.value, orNonecompare correctly.- Impasse dedup early-return is safe:
_build_hitl_decisiondoes not mutatecontract.decisions, so the scan cannot match the not-yet-written decision; it returns aRoutingDecisionof the same shape as the success path, and skips the write.route_impassesstill setsmutated=True→ a harmless no-op re-save.test_repeated_escalation_dedupes_onto_existing_decisionconfirms only onecq-persists across two realroute_impassescalls. _pending_contract_decisionsbridged-ID filter keys off the queue decisions'context(Open contract question (cq-\d+),), not contract question text, so a question body containing that literal cannot self-suppress.reis imported;task_idis quoted once upstream.- All new tests exercise production helpers (real
route_impasses, realsave_contract/load_contract, realfind_duplicate_open_question, realregister_open_questionagainst a mocked gateway). No self-seeded goldens, no hand-built fixtures bypassing the helper, no name/behavior contradictions.
No blocking issues.
Non-blocking
Docstring drift. register_open_question's docstring still documents only Response: { ok: True, decision: {...}, id: "cq-N" } (sdlc.py:71-72); the dedup path now also returns deduped: true. The public docs cover it, but the function docstring is stale. One-line fix.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
Thanks for the re-review. The one new non-blocking observation is addressed. Docstring drift —
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review: dedupe contract cq-N decisions + surface in get_status
Re-reviewed the delta since my prior re-review at a3f955f. The shallow PR ref collapses to a single squashed commit (5e988c9), so the old reviewed object isn't fetchable; I confirmed the only authored change is the docstring fix named in the commit and traced it against the live code.
My one prior non-blocking observation is properly resolved:
-
Docstring drift on
register_open_question. TheResponse:section (sandbox/egg_agent_tools/handlers/sdlc.py:69-77) now documents the dedup path: on a dedup hit (same normalized question already registered and unanswered in the same phase) the existing decision is returned verbatim with an extradeduped: Trueand no contract write. This matches:- the actual return shape (
sdlc.py:138-143→{ ok, id, decision, deduped: True }), and find_duplicate_open_question's real predicate (shared/egg_contracts/decisions.py:107-118→type == "hitl",not resolved, same string-normalizedphase, equalnormalize_question).
The wording ("the same normalized question already registered and unanswered in the same phase") is accurate on all three counts — no overstatement, and it's now consistent with
docs/hitl-decisions.md. - the actual return shape (
No other behavioral change since a3f955f. The dedup logic, the option-difference warning (sdlc.py / impasse_routing.py), the hoisted CONTRACT_QUESTION_MAX_CHARS + truncate_question(), the get_status surfacing query, and all test additions are byte-identical to what I already verified end-to-end across the two prior approvals. A docstring-only delta introduces no new code paths, needs no test, and carries no security/correctness/robustness surface.
No blocking issues. Approving.
— Authored by egg
|
egg review completed. View run logs 6 previous review(s) hidden. |
Problem
Contract-resident
cq-NHITL decisions — registered by agents viaregister_open_questionor the impasse-escalation router — exhibited two distinct defects:Duplicate registration. The same underlying question registered by successive phases (or a re-run agent) minted distinct
cq-Nids with no dedupe. An operator who answeredcq-1still faced an identicalcq-4.next_cq_idonly guarantees unique sequential ids; nothing checked question content.Never surfaced on the status surface.
cq-Nlive on the SDLC contract (.egg-state/contracts/{id}.json), not the orchestrator decision queue.get_status.pending_decisionsis built solely from the queue, so unresolvedcq-Nwere invisible there. They only enter the queue via the post-gate bridge (_queue_and_await_contract_decisions), which promotes current-phase decisions after a phase_gate approval. Questions tagged for a later phase (e.g. registered during refine but taggedplan) sat unbridged and invisible — an operator driving viaget_status/wait-statusgot zero signal that HITL questions were pending, and a gate approval read as "nothing else pending" while questions were outstanding. Discovering them required an out-of-bandget_contract.Changes
Dedupe at registration. New
egg_contracts.decisions.find_duplicate_open_question(decisions, question, phase)normalizes the question (lower/strip/whitespace-collapse) and matches an existing unresolvedhitldecision in the same phase.register_open_question(and the impasse-escalation router) adopt the existingcq-Nidempotently — no second contract write — instead of minting a duplicate. The MCP/CLI registration response carriesdeduped: trueon a hit.Surface in
get_status. A new sibling fieldpending_contract_decisionslists unresolved, not-yet-bridgedcq-N(each withid/question/phase/options/scope: "contract"). Kept distinct frompending_decisionsso the documented two-wave resolve flow is unaffected; already-bridgedcq-N(mirrored into the queue) are filtered out so nothing is double-listed. Best-effort and issued last — never breaks the snapshot.Gate-approval guard. The
phase_gateresolve response now includesoutstanding_contract_decisions: the later-phase unresolvedcq-Nthat the approval leaves open (current-phase ones are promoted by the bridge, so only future-phase questions are genuinely outstanding). The operator is no longer told "proceeding" while HITL questions sit unanswered downstream.Docs:
docs/hitl-decisions.mdand the SDLCSKILL.mddocument the new field, the dedupe/idempotency behavior, and the gate-approval guard.Tests
tests/shared/egg_contracts/test_decisions.py—normalize_question+find_duplicate_open_question(dict & pydantic inputs, phase/resolved/type filtering).tests/sandbox/egg_agent_tools/test_handlers_sdlc.py— idempotent re-register (no second mutate); resolved duplicate does not suppress a fresh registration.orchestrator/tests/test_mcp_tools_enrichment.py—_pending_contract_decisions(surfacing, resolved/non-hitl exclusion, already-bridged filtering, request-failure fail-open).orchestrator/tests/test_impasse_routing.py— repeated escalation dedupes onto the existing decision.orchestrator/tests/test_resolve_contract_decision_route.py—_outstanding_contract_hitlreports only unresolved later-phase questions; empty when no worktree.Full suite green (19,929 passed; the 2
reap-stale-egg-imagesfailures are pre-existing host-env noise unrelated to this diff).