Bridge contract HITL decisions to CLI in local mode#909
Conversation
Contract decisions created by agents via `egg-contract add-decision` were invisible in local mode because the CLI only polled orchestrator decisions. This adds a CLI-level bridge that reads pending decisions directly from the contract JSON file and surfaces them as an interactive [q] option in the phase gate menu. Approving with unanswered questions triggers a warning. Issue: #908
There was a problem hiding this comment.
Code Review: Bridge contract HITL decisions to CLI
Thorough review of all changes. The approach is sound — reading contract decisions
directly from the JSON file to bridge local-mode HITL without orchestrator changes.
A few issues need addressing before merge.
1. TOCTOU race in _resolve_contract_decision (correctness)
File: sandbox/egg_lib/sdlc_hitl.py:167-219
The read-modify-write cycle (read JSON → mutate in-memory → write back) has no
file locking. If an agent writes to the contract file between this function's
read_text() and os.replace(), the agent's changes are silently overwritten.
This is a real scenario: the agent process runs concurrently with the CLI. The
agent calls egg-contract add-decision (which mutates the file via the gateway),
and the human simultaneously resolves a decision through the CLI. The gateway's
contract mutation endpoint has its own atomicity guarantees, but this CLI code
bypasses the gateway entirely and operates directly on the file.
The atomic write (tempfile + rename) protects against partial writes, but not
against lost updates.
Suggested fix: Use fcntl.flock() or check the gateway's mutation API
(which already exists at /api/v1/contract/mutate) instead of direct file writes.
At minimum, add a comment acknowledging this limitation so future readers know
it's a known tradeoff.
2. Stale pending_contract used for approve warning (correctness)
File: sandbox/egg_lib/sdlc_hitl.py:547-554
When the user selects "3" (approve), the code checks the pending_contract
list that was loaded at the top of the while True loop iteration. If the user
just answered questions via [q] in the same iteration (which continues back
to the top — actually this is fine since continue re-enters the loop and
reloads), wait — tracing the flow:
After choice == "q", _handle_contract_questions runs, then continue goes
back to the top of while True, which reloads pending_contract. So this path
is correct.
However, the approve path at line 549 checks if pending_contract: using the
list loaded at line 492-495 in the same iteration. Between loading and
approving, the file could have changed (another process resolved decisions).
This is a minor UX issue — the warning may fire even when all questions are
already answered externally. Low severity, but worth a comment.
No change required — just noting.
3. _handle_contract_questions has no unit tests (testing gap)
There are no direct tests for _handle_contract_questions. The integration tests
(test_phase_gate_shows_q_option, test_approve_with_pending_warns) exercise
the phase gate integration but never test the [q] → _handle_contract_questions
flow itself.
Untested scenarios:
- Option-based questions (numbered choice selection)
- Free-text input
- Skip (
s) behavior - Quit (
q) mid-question _resolve_contract_decisionfailure during question handling- EOFError / KeyboardInterrupt during free-text input
This function has branching interactive logic (two input modes, multiple
escape paths). Add unit tests for it.
4. Free-text "q" and "s" are intercepted as commands (UX/correctness)
File: sandbox/egg_lib/sdlc_hitl.py:277-279
if answer.lower() == "q":
return "quit"
if answer.lower() == "s" or not answer:
continueIf the human types q or s as a legitimate answer to a free-text question,
it's swallowed as a command instead of being recorded as the answer. This is
confusing — the prompt says [s] Skip [q] Return to menu but doesn't indicate
these are reserved within the answer input itself. For option-based questions
this is fine (they use _prompt_choice with a defined valid set), but free-text
input shouldn't have invisible reserved words.
Suggested fix: Either:
- Use a distinct prefix for commands (e.g.,
/qand/s), or - Print the reserved words more prominently in the free-text prompt:
"Enter answer (or 's' to skip, 'q' for menu):", or - Only intercept these on a separate prompt after the user hits Enter with
empty input.
5. pipeline_id naming confusion in _get_contract_key (readability)
File: sandbox/egg_lib/sdlc_hitl.py:492
contract_key = _get_contract_key(pipeline_mode, issue_number, pipeline_id)The pipeline_id parameter of _handle_phase_gate is the orchestrator
pipeline ID (e.g., "issue-42"). In issue mode, _get_contract_key correctly
ignores it and uses issue_number. In local mode, it returns pipeline_id
(e.g., "local-a1b2c3d4"), which matches the contract file naming convention.
This works correctly, but the function's parameter name pipeline_id pulls
double duty (orchestrator ID vs. contract key source). A brief inline comment
at the call site would prevent future confusion:
# pipeline_id doubles as contract key in local mode (e.g., "local-a1b2c3d4")
contract_key = _get_contract_key(pipeline_mode, issue_number, pipeline_id)6. _get_contract_key returns None for issue_number=0 (minor)
File: sandbox/egg_lib/sdlc_hitl.py:138
return str(issue_number) if issue_number else Noneissue_number=0 is falsy, so this returns None. GitHub issue numbers start
at 1, so this is not a real-world issue. Noting for completeness — the same
pattern exists in _get_draft_path:44, so it's consistent with the codebase.
7. No test for _handle_contract_questions via the [q] path (testing gap)
File: tests/sandbox/test_sdlc_hitl.py
The integration tests mock input to select "3" (approve) and verify the
[q] option appears in output, but never actually exercise the "q" choice
path through the phase gate. Add a test that selects "q", answers a question,
then approves — this is the primary user flow this PR enables.
Summary
Blocking:
- Issue #3/#7: Add tests for
_handle_contract_questionsand the[q]→ answer → approve flow. This is the core feature of the PR and it has no test coverage for the actual interaction. - Issue #4: Free-text
"q"/"s"interception. Users who type single-letter answers will hit this. Fix or document prominently.
Non-blocking:
- Issue #1: TOCTOU race — add a comment acknowledging the limitation at minimum.
- Issue #5: Clarifying comment on
pipeline_iddual role.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- Fix free-text command interception: use /q and /s prefixes instead of bare q/s to avoid swallowing legitimate single-letter answers (issue #4) - Add TOCTOU race condition comment on _resolve_contract_decision documenting the known limitation and future fix path (issue #1) - Add clarifying comment on pipeline_id dual role at call site (issue #5) - Add unit tests for _handle_contract_questions: option-based selection, free-text input, skip (/s), quit (/q), EOFError, KeyboardInterrupt, resolve failure, and literal q/s as valid answers (issues #3/#7) - Add integration test for full [q] → answer → approve flow through the phase gate (issue #7)
There was a problem hiding this comment.
All actionable feedback addressed in a5d94a9. Summary:
Issue #1 (TOCTOU race): Added docstring comment on _resolve_contract_decision acknowledging the read-modify-write race and noting the gateway's /api/v1/contract/mutate endpoint as the future fix path.
Issue #3/#7 (missing tests): Added 13 unit tests for _handle_contract_questions covering: option-based selection, free-text input, skip (/s), quit (/q), EOFError, KeyboardInterrupt, resolve failure, and literal q/s as valid free-text answers. Also added an integration test for the full [q] → answer → approve flow through the phase gate. All 116 tests pass.
Issue #4 (free-text interception): Changed free-text reserved words from bare q/s to /q//s prefixes. Users can now type q or s as legitimate answers without them being swallowed as commands.
Issue #5 (pipeline_id naming): Added inline comment at the _get_contract_key call site clarifying the dual role of pipeline_id in local mode.
Issues #2/#6: Acknowledged as non-actionable per the review (no change required).
— Authored by egg
There was a problem hiding this comment.
Re-review: Bridge contract HITL decisions to CLI (a5d94a9)
Review of changes since 577b92f. All previous blocking issues have been properly addressed.
Previous feedback verification
Issue #1 (TOCTOU race): Docstring comment added to _resolve_contract_decision acknowledging the limitation and referencing the gateway mutation endpoint as the future fix path. Adequate.
Issue #3/#7 (missing tests): 12 new tests added — 11 unit tests for _handle_contract_questions covering option-based selection, free-text input, skip (/s), quit (/q), EOFError, KeyboardInterrupt, resolve failure, and literal q/s as valid free-text answers. Plus 1 integration test for the full [q] → answer → approve flow through the phase gate. The core feature now has test coverage. Adequately addressed.
Issue #4 (free-text interception): Changed from bare q/s to /q//s prefixes. Tests confirm bare q and s are saved as legitimate answers. Properly fixed.
Issue #5 (pipeline_id naming): Inline comment added at the call site. Clear.
New observations (non-blocking)
1. Pre-existing: _prompt_choice EOF returns "c" which crashes option-based questions
_prompt_choice returns "c" on EOFError/KeyboardInterrupt (line 374). In _handle_contract_questions, the option-based path (line 256-257) sets valid = valid_nums | {"s", "q"} — "c" is not in this set. But _prompt_choice returns "c" without checking valid, so it bypasses the retry loop. The result: int("c") - 1 at line 265 raises ValueError.
This is pre-existing (not introduced by this delta) and only triggers on EOF during option-based contract questions, which is an edge case in an interactive CLI. Not blocking, but worth a follow-up to add "c" handling in _handle_contract_questions's option path, or to make _prompt_choice respect the valid set on EOF.
2. No multi-question test
All unit tests use a single pending decision. The iteration logic (enumerate(pending)) is straightforward, but a test with 2+ questions where the user answers one and skips another would exercise the real-world scenario more faithfully. Not blocking.
Summary
All blocking issues from the previous review are properly fixed — not superficially, but with correct code changes and corresponding tests. The /q//s prefix approach is clean and the test coverage is thorough for the feature's core paths. The two observations above are non-blocking and can be addressed separately.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Address non-blocking suggestions from re-review:
- Handle _prompt_choice returning "c" on EOF/KeyboardInterrupt in the
option-based path of _handle_contract_questions. Previously this would
fall through to int("c") and raise ValueError. Now treats "c" as quit.
- Add test for EOF during option-based questions to prevent regression.
- Add multi-question test (answer one, skip another) to exercise the
iteration logic with 2+ pending decisions.
There was a problem hiding this comment.
Both non-blocking suggestions from the re-review addressed in 40729c8:
Observation #1 (_prompt_choice EOF crashes option-based questions): _handle_contract_questions now handles the "c" return from _prompt_choice on EOF/KeyboardInterrupt, treating it as quit instead of letting it fall through to int("c"). Added a test for this path.
Observation #2 (multi-question test): Added a test with 2 pending decisions where the user answers one (option-based) and skips the other (free-text with /s), exercising the iteration logic.
All 118 tests pass.
— Authored by egg
Update documentation to reflect changes from commit aa36980: - Contract decisions (created via egg-contract add-decision) now surface as an interactive [q] option in the CLI phase gate menu - Humans can answer contract questions directly from the terminal - Phase gate warns if approving with unanswered questions Updated docs: - CLAUDE.md: Clarified that contract decisions surface in CLI - docs/hitl-decisions.md: Added "Contract Decision Bridge" subsection - docs/guides/sdlc-pipeline.md: Updated phase gate description Triggered by: #909 Authored-by: egg
There was a problem hiding this comment.
Re-review: Bridge contract HITL decisions to CLI (40729c8)
Review of changes since a5d94a9. Both non-blocking suggestions have been properly addressed.
Previous feedback verification
Observation #1 (_prompt_choice EOF crashes option-based questions): Fixed correctly. _handle_contract_questions now checks choice in ("q", "c") at line 260, catching the "c" sentinel that _prompt_choice returns on EOF/KeyboardInterrupt. This prevents int("c") from raising ValueError at line 265. The fix is minimal and correct — "c" is not in the valid set so it can only arrive via the exception path.
Observation #2 (multi-question test): Added test_handle_questions_multi_answer_one_skip_one with 2 pending decisions: answers the first via option selection ("1" → PostgreSQL), skips the second via /s. Verifies contract file state — first decision resolved, second still unresolved. The mock side_effect correctly feeds "1" to _prompt_choice's internal input() and "/s" to the free-text path's direct input(). Test is sound.
EOF test: test_handle_questions_eof_during_option_based sets mock_input.side_effect = EOFError, which triggers _prompt_choice's exception handler returning "c", which is caught by the new "c" check. Correctly verifies the fix.
New code examination
The delta is two changes:
-
Line 260:
if choice == "q":→if choice in ("q", "c"):— Correct. No other callers of_handle_contract_questionsare affected. The"c"case semantically means "cancel/quit", consistent with returning"quit". -
Two new tests (76 lines): Both are well-structured, test real scenarios, and use the existing
_write_contracthelper consistently with the rest of the test class. No issues.
No new issues found in this delta.
— Authored by egg
|
egg review completed. View run logs 4 previous review(s) hidden. |
Update documentation to reflect changes from commit aa36980: - Contract decisions (created via egg-contract add-decision) now surface as an interactive [q] option in the CLI phase gate menu - Humans can answer contract questions directly from the terminal - Phase gate warns if approving with unanswered questions Updated docs: - CLAUDE.md: Clarified that contract decisions surface in CLI - docs/hitl-decisions.md: Added "Contract Decision Bridge" subsection - docs/guides/sdlc-pipeline.md: Updated phase gate description Triggered by: #909 Authored-by: egg Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com>
Summary
(via
egg-contract add-decision) are visible in local mode[q] Answer open questions (N pending)option to the phase gate menuwhen unresolved contract decisions exist
[y/n]confirmation
Details
Contract decisions were stored in
.egg-state/contracts/{key}.jsonbut onlysurfaced via GitHub comments in remote mode. The CLI only polled orchestrator
decisions, leaving local-mode users unaware of pending questions.
This adds four functions to
sdlc_hitl.pythat read/resolve contract decisionsdirectly from the JSON file — no orchestrator changes needed. The phase gate
menu dynamically shows the
[q]option and re-checks pending decisions eachloop iteration so answered questions disappear immediately.
Issue: #908
Test plan
pytest tests/sandbox/test_sdlc_hitl.py -v— 104 tests pass (16 new)make lint— cleanegg-sdlclocally against a pipeline with contract decisions,verify
[q]appears and questions are interactiveAuthored-by: egg