test(integration): pin HITL HTTP round-trip invariants on k3s (#2634) - #2645
Conversation
Adds `integration_tests/regression/` as the home for cross-module k3s regression tests (created in #2474 spirit but never landed) and a HITL-focused test module that pins the part of the round-trip reachable from the test runner without pod-level provider injection. Coverage: * Every `/api/v1/pipelines/<id>/decisions/...` route is registered on the live blueprint (no 404 from the Flask routing layer). * `/resolve` and `/cancel` fail closed with 401/503 on missing / bogus / non-Bearer auth — #1769 HITL auto-approval parity. * Agent-facing queue/list/get/status routes return structured 404 with the canonical `{"success": false, "message": ...}` envelope for unknown pipeline ids. * Malformed POST /decisions bodies (missing question, invalid decision_type, invalid phase) get structured 400s, never 500s. * `/resolve` with auth + empty body returns 400 referencing the missing `resolution` field — pins the body-validation-after-auth ordering so a refactor can't leak decision-existence info via differential responses. * `InvalidPipelineIdError` shapes (path-traversal candidates, malformed ids) surface as 400 rather than 500. Out of scope — these need pod-level LLM-trajectory injection per the `feedback_scripted_provider_pod_injection` memo and #2474's constraint write-up (called out in the module docstring): * AWAITING_HUMAN transition driven by `register_open_question`. * Resume out of AWAITING_HUMAN within #2430's bypass deadline. * HITL during slice phases / combined with `restart_agent` / multiple sequential HITLs. * HITL timeout behaviour. Also drops the never-landed shape of #2474's recovered attempt (`regression-<12hex>` pipeline ids that trip `state_store.PIPELINE_ID_PATTERN` → InvalidPipelineIdError, masking 404 assertions). New `deterministic_pipeline_id` helper emits `pipeline-<8hex>`, which matches the regex, and a self-test pins this so a future change to the helper can't silently turn 404 assertions into 400 assertions. Tested: `make test-integration` green locally on k3s (157 passed, 20 skipped, 0 failed) — the new 29 cases land alongside the existing suite.
…, oversize Extends the regression module added in 71b371d to address the gap audit from PR #2645's first round of review: * HTTP method enforcement — 405 on disallowed methods (DELETE/PUT/PATCH on POST routes, GET on /resolve / /cancel, POST on /status). * Malformed JSON body — invalid JSON with application/json content-type surfaces as 400 (Werkzeug bad-JSON path). * Non-object JSON body (array / string / number / bool) — exposes #2656: `data.get("question")` raises `AttributeError` when `data` is a list/scalar, leaking a 500. Pinned as xfail(strict=True) so a fix for #2656 flips them XPASS and the next author drops the mark. The `null` case works correctly today (coerces to `{}` via `or {}`) — PASS-pinned to guard the coercion path. * Resolve payload edge cases — explicit null / empty string / dict / list / int / bool=False / whitespace-only. Pins which values trip the `if not resolution` validation vs which fall through to the state-store lookup (the dict/list cases also exercise the json.dumps normalisation path). * Cancel on unknown decision with auth — 404 envelope with pipeline id in the message (regression guard for operator-triage diagnostic). * Oversized payload — 5 MB question must not 500 or hang. Also opens two follow-up issues for what's still infra-blocked: * #2656 — `POST /decisions` returns 500 on non-object JSON bodies (real bug, surfaced by these tests). * #2657 — Test infra for ephemeral pipelines + pod-level LLM injection unblocks concurrency tests, the real round-trip, and the issue's gap-audit list (multiple sequential HITLs, slice-phase HITL, etc.). Tested: `make test-integration` green — 182 passed, 20 skipped, 4 xfailed (the #2656 cases).
… 400
`queue_decision` and `resolve_decision` did `data = request.get_json() or {}`
then `data.get(...)`. When the body was syntactically-valid JSON but not an
object (list / scalar), `.get` raised `AttributeError` and the handler's
generic `except Exception` mapper turned it into a 500. `queue_decision` is
unauthenticated by design, so a misbehaving agent could flood the
orchestrator's error logs with stack traces.
Both handlers now reject non-dict bodies with the canonical 400 envelope
before any `.get` call. `null` still coerces to `{}` via `or {}` and falls
through to the existing missing-field 400 branches.
The four xfail-marked primitive-body parametrize cases in the regression
test (added in PR #2645) now pass on real behavior, so the marks are
dropped. New unit tests in `test_decisions_routes.py` pin the same
invariant on both endpoints in the inner-loop suite.
This comment has been minimized.
This comment has been minimized.
get_state_store_for_pipeline was only validating pipeline IDs inside load_pipeline, which is never reached when EGG_REPO_PATH points to an empty directory (the k8s integration test environment). In that case, discover_repo_paths returns [] and PipelineNotFoundError is raised directly — bypassing _validate_pipeline_id entirely and returning 404 instead of 400 for malformed IDs like regression-too-short, issue-, issue-abc. Fix by calling _validate_pipeline_id at the top of get_state_store_for_pipeline before any repo search.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Thorough review of the integration test surface, the #2656 fix, and the _validate_pipeline_id hoist. No blocking issues. The integration assertions match real handler behavior (verified against orchestrator/routes/decisions.py, orchestrator/lifecycle_auth.py, and orchestrator/state_store.py). The except E1, E2: syntax in conftest.py is valid Python 3.14 (PEP 758) given the project's requires-python = ">=3.14". Below are items I would address before/with merge — the first two are the ones I'd prioritize.
Should fix before merge
1. test_deterministic_pipeline_id_is_syntactically_valid duplicates the regex it's supposed to pin against
integration_tests/regression/test_hitl_round_trip.py:1042-1050 re-implements state_store.PIPELINE_ID_PATTERN inline:
pattern = _re.compile(
r"^("
r"issue-[0-9]+(-[a-z0-9]+)*"
r"|[A-Z][A-Z0-9]+-[0-9]+(-[a-z0-9]+)*"
r"|local-[0-9a-f]{8}"
r"|pipeline-[0-9a-f]{8}"
r"|pr-[0-9]+"
r")$"
)The test's whole stated purpose is to prevent deterministic_pipeline_id from silently drifting away from the validator (so that 404 assertions don't quietly turn into 400 assertions). But the test now compares against a hand-typed copy of the regex — if state_store.PIPELINE_ID_PATTERN grows a new pipeline-id shape, or deterministic_pipeline_id is updated to emit one, this test keeps using the stale copy and silently passes. That is the exact failure mode the docstring warns about, just one level up.
Fix:
from state_store import PIPELINE_ID_PATTERN
...
for nodeid in samples:
pid = deterministic_pipeline_id(nodeid)
assert PIPELINE_ID_PATTERN.match(pid), ...Same applies to importing _validate_pipeline_id and asserting it doesn't raise — even simpler.
2. lifecycle_bearer shells out to kubectl once per parametrized test
integration_tests/regression/conftest.py:118 declares lifecycle_bearer as function-scoped, and lifecycle_secret() runs kubectl -n egg-system get secret gateway-secrets ... with a 15 s timeout on every invocation. The test classes that depend on it parametrize over multiple bodies — TestHitlResolvePayloadEdgeCases alone fans out to 7 cases. On a slow cluster or a developer laptop without RBAC (where the call ultimately fails after the timeout), this multiplies into tens of seconds of pure subprocess overhead, and pytest.skip in a function-scoped fixture is invoked on each iteration too.
Change to @pytest.fixture(scope="session") — the lifecycle secret is a singleton per cluster, there is no reason to re-read it.
Non-blocking suggestions
3. except OSError, subprocess.TimeoutExpired: — use parens for readability
integration_tests/regression/conftest.py:104 and :114 rely on PEP 758 (parens-less except), which is valid in Python 3.14 but still uncommon enough that many reviewers will read it as Python 2 syntax for except Type as name: on first glance. Linters and IDE syntax highlighters that haven't caught up will also flag it. Prefer the explicit tuple:
except (OSError, subprocess.TimeoutExpired):
return None(Same for the ValueError, UnicodeDecodeError clause.)
4. Falsy non-dict bodies bypass the new "JSON object" message
orchestrator/routes/decisions.py:554-556:
data = request.get_json() or {}
if not isinstance(data, dict):
return make_error_response("Request body must be a JSON object")For truthy non-dicts ([1,2,3], "a", 42, true) you get the new explicit "Request body must be a JSON object" 400. For falsy non-dicts ([], 0, false, "") the or {} coerces them to {} and the operator gets "Missing question" instead — different message for the same class of error.
The status code is still 400 in all cases, so this is a UX nit rather than a bug, but it would be trivial to make consistent:
raw = request.get_json(silent=True)
if raw is not None and not isinstance(raw, dict):
return make_error_response("Request body must be a JSON object")
data = raw or {}The integration test TestHitlMalformedJsonBody::test_non_object_json_body_400 happens to only exercise truthy non-dicts plus null (which goes through the or {} coercion to "Missing question"). It would not detect this inconsistency.
5. _validate_pipeline_id is imported across module boundaries despite its underscore prefix
orchestrator/routes/__init__.py:186 imports a private function:
from state_store import (
PipelineNotFoundError,
_validate_pipeline_id,
...
)This works but is a smell — Python treats the underscore prefix as the module's own "do not import" signal. Either promote _validate_pipeline_id to validate_pipeline_id (and update internal callers), or expose a public helper in state_store that wraps the private one. Otherwise a future refactor inside state_store may move/rename _validate_pipeline_id without checking external imports.
6. The hoisted _validate_pipeline_id runs twice in the fast path
orchestrator/routes/__init__.py:194 now calls _validate_pipeline_id(pipeline_id) unconditionally. The downstream store.load_pipeline(pipeline_id) call then runs through _get_pipeline_path which also validates. This is harmless (regex match on a short string), but the comment "Validate format before any repo lookup so InvalidPipelineIdError is always raised for malformed IDs even when no repos are discovered" should call out that the validation is intentionally redundant with the downstream check, so a future reader doesn't "clean up" the duplicate.
7. TestHitlOversizedPayload docstring says "50 MB"; the code uses 5 MB
integration_tests/regression/test_hitl_round_trip.py:989 says "a DoS-shaped body — 50 MB of JSON" but line 1004 uses "x" * (5 * 1024 * 1024) (5 MB). The PR description also says 5 MB. Pick one — either bump the test to 50 MB or fix the docstring.
8. test_invalid_json_with_json_content_type_400 skips the envelope assertion
integration_tests/regression/test_hitl_round_trip.py:782-804 only asserts status_code == 400. Every other malformed-body test in this module calls _assert_error_envelope. Werkzeug's BadRequest is rendered through api.py's handle_unhandled_exception so the canonical envelope should be present — pin it here too:
_assert_error_envelope(resp, f"POST {path} body={raw_body!r}")This would catch a regression where the JSON-decode error escapes the app-level error mapper.
9. TestHitlRoutesRegistered.test_route_is_not_404 is weaker than its name implies for non-lifecycle routes
test_hitl_round_trip.py:361-364 accepts resp.status_code in (200, 400, 404) for unauthed agent-facing routes. The intent is to prove "the route exists and dispatched into the handler" — but Flask's stock 404 for an unregistered route also has status_code == 404, and the orchestrator's handle_unhandled_exception renders Werkzeug's 404 with the canonical {"success": false, "message": ...} envelope too. So a regression that drops one of these routes from the blueprint would still pass this test.
For the 404 branch specifically, assert that the message references the pipeline id (which the handler embeds but Flask's stock 404 description does not):
if resp.status_code == 404:
body = _assert_error_envelope(resp, ...)
assert regression_pipeline_id in (body.get("message") or ""), (
f"{method} {path}: 404 envelope must reference pipeline id "
f"(otherwise this is a Flask routing 404, not a handler 404)"
)10. test_invalid_pipeline_id_400 for path-traversal IDs accepts either 400 or 404
test_hitl_round_trip.py:676-680 accepts 400 or 404 for .., ../etc/passwd, foo/bar. The comment correctly notes Flask normalises .. before our handler runs, so 404 is from the URL parser, not from InvalidPipelineIdError. But on the 404 branch you should pin that the message does NOT look like a pipeline-not-found ("pipeline … not found"), otherwise a regression where the regex check is removed and Flask routes the literal .. segment to the state-store would silently pass.
11. _format_path always passes decision_id even for paths that don't use it
test_hitl_round_trip.py:254-255 — str.format silently ignores extras, so path.format(pipeline_id="x", decision_id="y") on a template without {decision_id} works. This is mostly harmless but reads as a bug to anyone scanning the parametrized table. Either filter the kwargs to the placeholders present in the template, or split into _format_pipeline_path / _format_decision_path.
12. Sweep-tracking for the rest of the request.get_json() or {} + .get pattern
The PR description correctly flags that routes/messages.py, routes/health.py, routes/containers.py, and routes/anchors.py all share the same #2656-shaped pattern and are intentionally out of scope. #2657 (per the description) tracks the HITL-coverage gaps but not this sweep. Either expand #2657 or file a separate follow-up so the sweep doesn't fall through the cracks.
The fix for #2656 is correct, the validation hoist in get_state_store_for_pipeline is sound (PipelineNotFoundError was previously masking InvalidPipelineIdError when no repos were discovered — verified against routes/__init__.py post-PR), and the new integration tests do exercise the real production code path through the live orchestrator. The two "should fix" items above are the only ones I'd actually hold the PR for; the rest are quality-of-life improvements.
— 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.
… HITL and BRC regression fixtures Both branches added integration_tests/regression/conftest.py with different, non-overlapping fixture sets: - HEAD (PR #2645): HITL HTTP round-trip helpers (deterministic_pipeline_id, lifecycle_secret, lifecycle_bearer, regression_pipeline_id). - origin/main (#2649): BRC consensus fixtures (event_capture, filter_events fixture, single/two_reviewer/advisory_blocker_graph, autouse _reset_tracker_registry). Resolution is additive — keep both fixture sets in the same conftest. The HITL tests use the lifecycle/pipeline-id helpers; the BRC tests use the event/tracker/graph fixtures. None of them collide.
Address review feedback on PR #2645: - Replace inline PIPELINE_ID_PATTERN copy in test_deterministic_pipeline_id_is_syntactically_valid with a real call to validate_pipeline_id — the hand-typed regex was the exact failure mode the test exists to prevent, one level up. - Make lifecycle_bearer session-scoped so kubectl shells out once per session instead of per parametrized case. - Use parens for multi-exception except clauses (more readable than PEP 758 paren-less form). - Make non-object body rejection consistent across truthy and falsy non-dicts in queue_decision/resolve_decision: previously []/0/false/"" fell through "or {}" to "Missing question" with a misleading message. - Expose state_store.validate_pipeline_id as the public alias so callers (routes/__init__.py) don't reach across the underscore boundary. - Document that validate_pipeline_id() in get_state_store_for_pipeline is intentionally redundant with the downstream _get_pipeline_path check. - Fix docstring "50 MB" → "5 MB" to match the code in TestHitlOversizedPayload. - Add envelope assertion to test_invalid_json_with_json_content_type_400. - Strengthen TestHitlRoutesRegistered 404 branch — assert the message references the pipeline id so a Flask routing 404 (route dropped from blueprint) doesn't silently pass. - Pin TestHitlPipelineIdValidation 404 branch — message must not look like pipeline-not-found for path-traversal ids. - Filter _format_path kwargs to placeholders present in the template so the helper doesn't pass decision_id to templates that don't reference it. - Sweep the same "or {}" + ".get" pattern in routes/messages.py, routes/health.py, routes/containers.py, routes/anchors.py — the follow-up the reviewer asked us to track is fixed inline.
|
Thanks for the thorough review. All 12 items addressed in-PR — commit Should fix before merge1. 2. Non-blocking suggestions3. 4. Falsy non-dict bodies bypass the new "JSON object" message — 5. 6. Hoisted 7. 8. 9. 10. 11. 12. Sweep — Authored by egg |
This comment has been minimized.
This comment has been minimized.
…tests' into egg/egg-exec-20260512-192439-3133/work
Conflict Resolution SummaryResolved merge conflicts with
Background. Two PRs landed against the freshly-created
The two fixture sets are non-overlapping — different fixtures, different test modules. Resolution kept both in one file, with a combined docstring documenting which subsystem each fixture set serves and which tests consume it. Imports merged: HEAD's A subsequent commit on the PR branch ( Local verification:
Please review: Nothing semantically risky — this is a straightforward additive merge of two independent fixture sets at the same path. Both consuming test suites should be unaffected. — 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.
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.
There was a problem hiding this comment.
Re-review of PR #2645 against commit f75cc04 (delta since 60cae820: a149705 review-feedback + d3ab972 main-merge + f75cc04 format-fix).
Previous-review items — verified
| # | Item | Status |
|---|---|---|
| 1 | test_deterministic_pipeline_id_is_syntactically_valid duplicates the regex |
✅ Fixed — now calls state_store.validate_pipeline_id (test_hitl_round_trip.py:951-969). |
| 2 | lifecycle_bearer shells out per parametrized test |
✅ Fixed — @pytest.fixture(scope="session") (conftest.py:133). |
| 3 | except OSError, subprocess.TimeoutExpired: — use parens |
|
| 4 | Falsy non-dict bodies bypass the "JSON object" message | ✅ Fixed — raw is not None and not isinstance(raw, dict) in queue_decision (decisions.py:554-557) and resolve_decision (decisions.py:716-719). Parametrized cases for []/0/false/"" added in both test_hitl_round_trip.py:716-719 and test_decisions_routes.py:472,613. |
| 5 | _validate_pipeline_id imported across module boundaries |
✅ Fixed — validate_pipeline_id = _validate_pipeline_id alias added (state_store.py:115); routes/__init__.py:188 imports the public name. |
| 6 | Hoisted _validate_pipeline_id runs twice in fast path |
✅ Fixed — comment in routes/__init__.py:191-197 calls out the intentional redundancy. |
| 7 | TestHitlOversizedPayload docstring "50 MB" |
✅ Fixed — docstring now says "5 MB" (test_hitl_round_trip.py:900,909,925,932). |
| 8 | test_invalid_json_with_json_content_type_400 skips envelope assertion |
✅ Fixed — _assert_error_envelope(resp, ...) added at test_hitl_round_trip.py:700. |
| 9 | TestHitlRoutesRegistered.test_route_is_not_404 weaker than name implies |
✅ Fixed — inner 404-branch assertion at test_hitl_round_trip.py:235-241 requires the envelope message to reference the pipeline id. |
| 10 | test_invalid_pipeline_id_400 404-branch under-pinned |
✅ Fixed — test_hitl_round_trip.py:563-571 asserts the 404 message must NOT look like pipeline-not-found. |
| 11 | _format_path always passes decision_id |
✅ Fixed — test_hitl_round_trip.py:100-116 filters kwargs to placeholders the template references. |
| 12 | Sweep the request.get_json() or {} + .get pattern across other routes |
routes/messages.py, routes/anchors.py, routes/containers.py, routes/health.py — see "New observations" below. The sweep itself is correct but has no test coverage. |
Disposition notes / new observations
These are all non-blocking — the core PR is correct and the two should-fix items from the prior review are properly resolved.
A. The except parens fix landed in a149705 but was reverted by the auto-formatter in f75cc04
integration_tests/regression/conftest.py:119,129 is back to the PEP 758 paren-less form (except OSError, subprocess.TimeoutExpired: and except ValueError, UnicodeDecodeError:). I traced the patch chain: a149705 added parens, then f75cc04 ("Fix checks: apply automated formatting fixes") removed them. Ruff 0.15.12 has a PEP 758 auto-fix that the project's lint config is letting through.
The author's response comment to item #3 says "Both clauses now use explicit tuple form except (OSError, subprocess.TimeoutExpired):" — that was true at the time the comment was posted, but no longer reflects the merged state. Not a correctness issue (PEP 758 is valid Python 3.14+, and requires-python = ">=3.14"), but the disposition note in the response is misleading for future archaeology. If you want the parens for readability, the ruff rule that strips them needs to be disabled in pyproject.toml; otherwise close out item #3 honestly as "won't fix — formatter strips parens."
B. Sweep across 4 additional routes has no test coverage
The author bundled the #2656 pattern fix into routes/messages.py (both send_message and post_heartbeat), routes/anchors.py (create_or_update_anchor and gc_anchors), routes/containers.py (spawn_container and stop_container), and routes/health.py (resolve_pipeline_health_alerts). The production-code change is mechanically correct and mirrors the in-scope fix.
But the bundle added zero tests for these routes. test_messages.py, test_anchors_routes.py, test_container_*.py, and test_health_routes.py already exist in orchestrator/tests/ — the natural homes are right there. The author's response acknowledged "the fix is mechanical and trivially testable" but didn't add the trivial test. A future refactor that drops one of these isinstance guards will only be caught by a real client hitting it, not by CI.
These routes are authenticated (in contrast to the unauthenticated queue_decision that motivated #2656), so the log-spam risk is lower. Calling this non-blocking, but I'd add at least one parametrized test_non_object_json_body_returns_400 per swept module before considering this PR's job done — the original review explicitly asked for a tracked follow-up if the sweep wasn't done in-PR, and trading a follow-up for an untested sweep is a worse outcome than either alternative.
C. Behavior change for {} body in send_message / create_or_update_anchor
The sweep changed if not body: to if body is None: + if not isinstance(body, dict):. For body = {}:
- Before:
not bodywas True (empty dict is falsy) → "Missing request body" 400. - After:
body is Noneis False;isinstance({}, dict)is True → falls through tobody.get("from_role")/body.get("agent_id")→ "Missing from_role" / "agent_id not in URL/body matches" 400.
Both 400 with descriptive messages — this is a diagnostic improvement, not a regression. Worth a one-line callout in a future commit message; not worth blocking the PR.
D. health.py envelope key is "error" not "message"
routes/health.py:370 (the new line from the sweep) returns {"success": False, "error": "Request body must be a JSON object"}, consistent with neighboring lines 362/366 but inconsistent with decisions.py / messages.py / containers.py / anchors.py which use "message". The _assert_error_envelope helper in the new integration tests expects "message", so a regression-tier test against /health/alerts/resolve written with the canonical envelope would fail.
This is pre-existing inconsistency in health.py (lines 219/315/327 use "message"; 233/239/285/362/366 use "error") — not something this PR introduced. Mentioning in case the maintainer wants to track a follow-up to normalize, but health.py is not exercised by the new test surface so the inconsistency is invisible to this PR's invariants.
Conclusion
All blocking items from the previous review are properly resolved. The #2656 fix is correct end-to-end (production code + unit tests + integration tests). The hoist of validate_pipeline_id is sound and now uses the public alias. Items 7–11 (test rigor) are visibly tighter.
The two real callouts above (A: formatter-reverted parens with a misleading disposition note; B: untested sweep across 4 routes) are non-blocking but worth landing in a follow-up rather than letting them fall through the cracks the same way item #12 originally did.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
… HITL, k3s slice-spawn, and BRC regression fixtures Conflict in integration_tests/regression/conftest.py was purely additive: - HEAD adds HITL HTTP round-trip helpers (#2474, #2634) - main adds k3s slice-spawn helpers (#2632) - Both share the BRC consensus fixtures (#2635) unchanged Resolution: include both helper sections side-by-side, combine imports (base64, hashlib, json, time, typing.Any), and rewrite the module docstring to describe all three tiers.
Conflict Resolution SummaryResolved merge conflicts with
Both sides added new helpers to the same conftest file without touching each other's logic:
Resolution:
Verification:
Please review: No semantic decisions were needed — both sides are purely additive. Reviewer may want to spot-check the merged docstring and — Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review of PR #2645 since commit f75cc04. The only delta is 017710a957966045586942f06f29e8a6acdf7957 — a merge of origin/main that resolved an additive add/add conflict in integration_tests/regression/conftest.py and pulled in unrelated work from #2631/#2651/#2642/#2682.
Conflict resolution — verified
The merge combines three orthogonal fixture sets in conftest.py side-by-side:
- HEAD-side HITL helpers (
deterministic_pipeline_id,lifecycle_secret,lifecycle_bearer,regression_pipeline_id) at lines 81–177. - Main-side k3s helpers (
kubectl_get_pod_yaml,env_from_pod,spawnerfixture) at lines 180–308. - BRC fixtures (
_reset_tracker_registry,event_capture,filter_events, three reviewer-graph fixtures) at lines 311–407.
Imports merged correctly (base64, hashlib, json, time, typing.Any). Module docstring rewritten to describe all three tiers. No conflict markers remain anywhere in the tree (grep '<<<<<<<' integration_tests/regression/ is clean; the two repo-wide hits are in test fixtures / documentation strings).
Verified behavior:
pytest --co -q integration_tests/regression/collects 97 tests cleanly (62 HITL + 30 BRC + 5 k3s — matches the PR author's local verification).test_deterministic_pipeline_id_is_syntactically_validpasses locally (the only k3s-free HITL guard).- All explicit
from integration_tests.regression.conftest import …sites resolve:test_hitl_round_trip.py:47(HITL helpers),test_slice_spawn_env_threading.py:47-48andtest_slice_restart_branch_invariants.py:41-42(k3s helpers).
Previous-review concerns — status
| # | Item | Status |
|---|---|---|
| 1–12 | All items from the prior review | Preserved through the merge — no regressions. Item 3 (paren form) remains as previously noted: the auto-formatter strips them and PEP 758 makes the comma form valid in Python 3.14+, so this is closed as won't-fix per the prior re-review's disposition. |
New observations from the merge
A. integration_tests/regression/README.md is stale after the merge — non-blocking
The README was added by #2651 (k3s tier) and describes only k3s regression guards:
k3s regression guards that pin invariants the SDLC pipeline has regressed historically. Tests in here drive the real
KubernetesSpawneragainst the locally-deployed egg stack…
The merge brought the README in unchanged, but the directory now also hosts the HITL HTTP round-trip tier (this PR) and the BRC consensus tier (#2649) — most of which do not require k3s. The merged conftest docstring (lines 3–46) correctly describes all three tiers; the README disagrees. Suggested follow-up: extend the README's "What's covered today" table to include the HITL and BRC tiers, or split the README intro into per-tier sections so readers don't conclude the directory is k3s-only.
B. __all__ in conftest.py is incomplete — non-blocking nit
conftest.py:410-415 only re-exports the HITL helpers:
__all__ = [
"deterministic_pipeline_id",
"lifecycle_bearer",
"lifecycle_secret",
"regression_pipeline_id",
]The k3s helpers kubectl_get_pod_yaml, env_from_pod, and the spawner fixture are missing. Tests import them by name (not via from conftest import *) so collection works, but __all__ is the documented public surface — leaving the k3s helpers out makes the export list misleading. Same applies to any future readers of dir(conftest) or IDE auto-import. Either add them or drop __all__ entirely (main's pre-merge conftest didn't have one).
C. Documentation / production-code changes from main not in scope
The merge brought in:
- #2631 — contract-decision ID prefix split to
cq-N(shared/egg_contracts/decisions.py+ handler updates). - #2651 — k3s name truncation + restart race + slice label fixes (
orchestrator/kubernetes_client.py,orchestrator/kubernetes_spawner.py). - #2642 — cross-worktree branch-ref defence in
_commit_statefiles_to_worktree(orchestrator/routes/pipelines.py). - #2682 —
check-fixers.ymlretry bump.
Cross-checked that these don't interact with the HITL test surface this PR ships: HITL tests use placeholder decision IDs of the form missing-decision-<uuid> (test_hitl_round_trip.py:65), so the cq-N vs decision-N prefix split is invisible to them. Nothing in the pipeline-commit / k3s-spawn paths is exercised by the HITL invariants. No regressions expected from the merge.
Conclusion
The merge is a clean additive resolution. All previous-review concerns remain resolved. The two callouts above are non-blocking documentation gaps. The PR is in good shape to merge once CI's Test / aggregate is green on the k3s-requiring tiers.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Three non-blocking observations from recent reviews: - README.md described only the k3s tier; extend it to cover HITL HTTP round-trip and BRC consensus tiers that now live in the same directory. - conftest __all__ omitted k3s helpers (kubectl_get_pod_yaml, env_from_pod, spawner); add them so the documented export surface stays honest. - The #2656 sweep into routes/messages.py, routes/anchors.py, routes/containers.py, and routes/health.py had no test coverage. Add parametrized "non-object JSON body returns 400" tests covering both the truthy ([1,2,3]/"a"/42/true) and falsy ([]/0/false/"") branches in each swept route, matching the pattern pinned in test_decisions_routes.py. test_containers_routes.py is new; the other route test files already existed so the new TestNonObjectJsonBodyReturns400 class slots in at the end of each.
|
Thanks for the re-review. The three non-blocking observations across the last two reviews are addressed in-PR — commit Review at commit
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review of PR #2645 since 017710a957966045586942f06f29e8a6acdf7957. The delta is one commit (8dd7724) addressing the three non-blocking items from the prior re-review.
Items addressed — verified
A. README extended (prior item A) — ✅ resolved
integration_tests/regression/README.md now opens with a 3-tier table (k3s slice-spawn, HITL HTTP round-trip, BRC consensus), each with its own "What's covered today" subsection. The HITL tier table enumerates all 12 invariant classes from test_hitl_round_trip.py. The BRC tier points back to #2635 for the per-file breakdown (correct — those tests are byte-identical to what #2635 shipped). No staleness left.
B. __all__ extended with k3s helpers (prior item B) — ✅ partially resolved
conftest.py:410-422 now re-exports env_from_pod, kubectl_get_pod_yaml, and spawner with an inline comment explaining spawner is pytest-injected rather than directly imported. The k3s asymmetry I called out is fixed.
Non-blocking nit, same shape one tier deeper: the BRC fixtures from #2635 (event_capture, filter_events, single_reviewer_graph, two_reviewer_graph, advisory_blocker_graph, and the autouse _reset_tracker_registry) are still not in __all__. If __all__ is the documented public surface and spawner is listed for IDE / import * honesty, the BRC fixtures should be too — otherwise readers will conclude the BRC tier has no public surface. Either add them or drop __all__ entirely. Not blocking — minor consistency nit, the BRC fixtures were never in __all__ and weren't part of either reviewer's original critique.
C. Sweep route test coverage (prior review's item B) — ✅ resolved
The author added parametrized TestNonObjectJsonBodyReturns400 test classes covering both truthy ([1,2,3], "a", 42, true) and falsy ([], 0, false, "") JSON bodies in all 5 swept routes:
| File | Cases | Pattern |
|---|---|---|
test_messages.py |
16 (8 × send_message + 8 × post_heartbeat) |
Hits real blueprint, patches get_state_store_for_pipeline defensively |
test_anchors_routes.py |
16 (8 × create_or_update_anchor + 8 × gc_anchors) |
Hits real blueprint, mock Redis injected via existing app fixture |
test_health_routes.py |
8 (resolve_pipeline_health_alerts) |
Patches health_monitor.get_health_monitor to bypass the 503 monitor-init guard, asserts against body["error"] (correct — this route uses error not message, documented in the test docstring) |
test_containers_routes.py (new file) |
16 (8 × spawn_container + 8 × stop_container) |
Hits real blueprint, patches _get_backend with assert_not_called() to verify body validation runs before backend dispatch |
All 60 new tests pass locally; 63-test test_decisions_routes.py baseline still green. The _inject_lifecycle_auth autouse fixture in orchestrator/tests/conftest.py correctly injects the bearer for the @require_lifecycle_secret-protected routes (containers, health).
The assert_not_called() on _get_backend in the containers tests is a nice defense-in-depth — pins that a future refactor moving JSON validation below backend dispatch would surface as a regression rather than silently degrade.
No self-seeding goldens, no hand-built fixtures bypassing production code paths. Tests exercise the real make_error_response / _make_error envelope builders by hitting the real routes through Flask's test client.
Verification
pytest tests/test_containers_routes.py tests/test_anchors_routes.py tests/test_health_routes.py tests/test_messages.py -q— relevant suites pass.pytest tests/test_decisions_routes.py -q— 63 passed (decision-route baseline pinned by the in-scope #2656 fix).- Production code at
routes/messages.py:160,routes/anchors.py:109,routes/anchors.py:294,routes/containers.py:132,routes/containers.py:382,routes/health.py:369all use the canonicalif not isinstance(..., dict): return _make_error("Request body must be a JSON object")pattern. Envelope keys match per-route convention (messagefor decisions/messages/anchors/containers;errorfor health). git show a914229 -- routes/health.pyconfirms the pre-fix state —data = request.get_json() or {}thendata.get(...)— matches the docstring's claim about the swept bug.
Conclusion
All three non-blocking items from the prior re-review are properly addressed. The sweep is now fully tested. The one residual nit (__all__ still missing BRC fixtures) is genuinely non-blocking and a follow-up.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
The latest reviewer noted that __all__ in integration_tests/regression/conftest.py exposed the HITL helpers and k3s helpers but omitted the BRC consensus fixtures. Same shape one tier deeper as the previous fix — readers of dir(conftest) or 'import *' would conclude the BRC tier has no public surface. Add advisory_blocker_graph, event_capture, filter_events, single_reviewer_graph, two_reviewer_graph, and _reset_tracker_registry (autouse) to __all__ with an inline comment explaining the rationale mirrors the spawner entry.
|
Thanks for the re-review. The single non-blocking nit from this round is addressed in-PR — commit B. — Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review of PR #2645 since 8dd77240fc77f6e70f61d0778747a29cc9851e3a. The delta is a single commit (9bd4a82) addressing the lone non-blocking nit from my prior re-review.
Items addressed — verified
B. __all__ extended with BRC fixtures (prior non-blocking nit) — ✅ resolved
integration_tests/regression/conftest.py:430-435 now lists the six BRC consensus fixtures (_reset_tracker_registry, advisory_blocker_graph, event_capture, filter_events, single_reviewer_graph, two_reviewer_graph) under an inline-commented section mirroring the spawner rationale. All six names resolve at module level:
| Name | Source in conftest |
|---|---|
_reset_tracker_registry |
def _reset_tracker_registry (line 317), autouse |
advisory_blocker_graph |
def advisory_blocker_graph (line 400) |
event_capture |
def event_capture (line 338) |
filter_events |
from _helpers import ... filter_events (line 74) — the helper, not the filter_events_fixture function bound at line 371 |
single_reviewer_graph |
def single_reviewer_graph (line 383) |
two_reviewer_graph |
def two_reviewer_graph (line 389) |
The __all__ list is at module bottom, after all definitions, so name resolution is fine. The inline comment correctly flags that filter_events does double duty — the module-level binding is the imported helper, and the fixture is registered under the same name via @pytest.fixture(name="filter_events").
Notes (non-blocking, will not request changes)
-
_reset_tracker_registryin__all__is intentionally underscore-prefixed. Putting a_-prefixed name in__all__is the standard contradiction with the "private" convention —from conftest import *will pull it in despite the underscore. The author's rationale (autouse fixture, listed fordir(conftest)honesty) matches the prior reviewer's explicit ask, so I'm noting rather than objecting. If anyone touches__all__again, dropping the underscore or moving the entry to a "fixtures registered via decorator name" cluster would resolve the optics. -
filter_eventsin__all__exposes the helper, not the fixture function.from conftest import filter_eventsreturns_helpers.filter_events, not thefilter_events_fixturefunction. The inline comment acknowledges this, and since fixtures are injected by pytest rather than imported, this is unlikely to surprise anyone in practice. Mentioning for completeness.
Verification
- All 6 new
__all__entries resolve to module-level attributes (verified by reading the source). - The change is purely additive — no other files touched, no test or production code affected.
- The previously-resolved items from earlier re-reviews (README, k3s helpers in
__all__, swept-route 400 tests) are untouched on this branch.
Conclusion
The only outstanding non-blocking item from my prior re-review is now resolved. No new blocking issues. Approving.
— Authored by egg
|
egg review completed. View run logs 23 previous review(s) hidden. |
… HITL with pipeline-recovery + message-bus regression tiers Conflict was in integration_tests/regression/conftest.py module docstring only (no code conflicts). Both sides describe orthogonal regression tiers that have grown into the same directory: * PR side (HEAD): HITL HTTP round-trip helpers (#2474, #2634), BRC consensus (#2635), k3s slice-spawn / restart guards (#2632). * main side: Pipeline recovery / unpushed-commit salvage (#2633), Message store + event bus routing (#2640), BRC consensus (#2635), k3s slice-spawn / restart guards (#2632). Resolved by listing all five tiers and merging the closing paragraph to describe how each set of fixtures opts in. Code-level merges were clean — every fixture (lifecycle-secret/HITL, message-bus, recovery, BRC, k3s) is already preserved in the file from the prior auto-merge.
Conflict Resolution SummaryMerged
All other staged changes from DetailThe conflict was purely prose: both sides describe orthogonal regression tiers that grew into the same directory, and the conftest's existing fixtures (lifecycle-secret/HITL, message-bus, recovery, BRC, k3s) had already auto-merged cleanly below the docstring. The k3s spawn-parameter info that lived as a separate paragraph in HEAD is preserved inside main's consolidated k3s bullet (the Verification
Please review: The docstring resolution is a judgement call about tier ordering. I put the new tiers first (recovery → message-bus), then the PR's HITL tier, then BRC + k3s, which mirrors the additive order — happy to flip if you'd prefer HITL up top. — Authored by egg |
|
egg conflict resolution completed. View run logs |
…cts in non-object-body sweep tests Conflicts in four orchestrator test files where #2645 (a74b8aa, now on main) added its own non-object-body sweep with the same shape as this PR. Per the PR description, the fix shape matches verbatim — choose main's more comprehensive variant (8 params: array/string/number/bool/ empty-array/zero/false/empty-string vs. 4) and drop the PR's redundant TestCreateAnchor.test_create_non_object_body_returns_400 (covered by the new TestNonObjectJsonBodyReturns400 class). Files: - orchestrator/tests/test_anchors_routes.py - orchestrator/tests/test_messages.py - orchestrator/tests/test_health_routes.py - orchestrator/tests/test_containers_routes.py
…tes (#2689) * Fix #2673: reject non-object JSON bodies across agent-facing POST routes Sweeps the same `request.get_json() or {}` + `.get(...)` pattern fixed in #2656/#2645 across the remaining routes called out in the spot-check: Unauthenticated (agent-facing — same blast radius as #2656; a misbehaving agent could flood the orchestrator error logs with stack traces): * POST /<pipeline_id>/heartbeat (routes/messages.py) * POST /<pipeline_id>/messages (routes/messages.py) * POST /gc/<pipeline_id> (routes/anchors.py) * POST /pipelines/<id>/health/alerts/resolve (routes/health.py) Lifecycle-authed: * POST /<pipeline_id>/spawn (routes/containers.py) * POST /<pipeline_id>/containers/<container_id>/stop (routes/containers.py) All six handlers now return a 400 envelope ("Request body must be a JSON object") before any .get() call on a list/scalar body. `null` still coerces to {} so existing missing-field 400 branches are unchanged. For send_message — which already had `if not body: return 400` — the check is split into `body is None` + `isinstance(body, dict)` so a truthy non-dict (list/scalar) lands on the type-aware message rather than misleadingly falling through to "Missing request body". Unit tests parametrize the four primitive bodies (array / string / number / bool) per route, mirroring `test_non_object_json_body_returns_400` from test_decisions_routes.py: * test_messages.py — TestNonObjectJsonBodyReturns400 (send_message, post_heartbeat). * test_anchors_routes.py — test_gc_non_object_body_returns_400. * test_health_routes.py — test_non_object_body_returns_400. * test_containers_routes.py (new) — spawn + stop with lifecycle auth. * Fix #2673: extend non-object-body sweep to all flagged sites Addresses review feedback on #2689 — the original PR fixed 6 routes called out in the issue but the sweep was incomplete in the file the PR modified (the sibling ``create_or_update_anchor`` route in ``anchors.py``), and the same bug class was present in five other agent-facing route files the issue's catalogue missed. **Blocking fix (same file as the PR was already modifying):** - ``anchors.py`` — ``create_or_update_anchor`` **Sweep candidates from the reviewer (other files, same bug class):** - ``signals.py`` — ``handle_signal``, ``handle_signal_batch`` - ``contracts.py`` — ``mutate_contract``, ``validate_contract_mutation`` - ``pipelines.py`` — ``create_pipeline``, ``update_pipeline`` - ``progress.py`` — ``emit_progress`` All routes now use the explicit ``is None`` + ``isinstance(body, dict)`` split so a list/scalar JSON body lands on the canonical 400 ``"Request body must be a JSON object"`` envelope instead of crashing with ``AttributeError`` and returning 500. Each route gets a parametrized non-object-body test mirroring the existing pattern (array / string / number / bool — 28 new cases). * Address review: extend non-object-body sweep to webhooks; move test_signals fixture Per reviewer's non-blocking sweep flag, extend the same fix to `orchestrator/webhooks.py`: - `github_webhook` (line 140): add `isinstance(payload, dict)` check after JSON parse. Without `GITHUB_WEBHOOK_SECRET` configured, an unauthenticated `[1, 2, 3]` payload reached `payload.get("action")` at line 153 → AttributeError → 500. - `manual_trigger` (line 328): split `if not data:` into `is None` + `not isinstance(data, dict)`. Completely unauthenticated, so a list/scalar body fell through to `data.get("event")` → 500. New `test_webhooks.py` adds 8 parametrized non-object-body cases mirroring the existing pattern. Also move the `client` fixture in `test_signals.py` from the bottom of the file to next to the `app` fixture, per the same review. * Harmonize non-object body test matrix to 8 cases across PR-added tests The four test files that merged from main (test_anchors_routes, test_messages, test_health_routes, test_containers_routes) use an 8-param matrix: array, string, number, bool, empty-array, zero, false, empty-string. The five PR-added test classes used only the original 4-param matrix. All four extra cases (`[]`, `0`, `false`, `""`) fail `isinstance(_, dict)` and land on the canonical 400 envelope on every route in the sweep, so the additional coverage is a strict win. Also adds the `"json object" in body["message"]` assertion to match the envelope-text check used by the merged-from-main tests. --------- Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
Closes #2634.
Closes #2656.
Summary
integration_tests/regression/— the cross-module k3s regression tier called out in Expand integration test coverage #2474 but never landed — with a HITL-focused module pinning the parts of the round-trip reachable from the test runner./api/v1/pipelines/<id>/decisions/...: route registration, lifecycle-auth parity, 404 envelope shape, malformed bodies, HTTP method enforcement, payload edge cases, oversized payload,InvalidPipelineIdErrormapping.queue_decisionandresolve_decisionnow reject non-object JSON bodies with the canonical 400 envelope instead of 500; the 4 xfail-marked tests are flipped to PASS and matching unit tests pin the invariant in the inner-loop suite.What's pinned
TestHitlRoutesRegisteredTestHitlLifecycleAuth/resolveand/cancelreject missing / bogus / non-Bearer headers — #1769 parity.TestHitlUnknownPipelineReturns404TestHitlQueueDecisionPayloadValidationdecision_type/ invalidphase/ no-body → structured 400 (or 415 for no Content-Type), never 500.TestHitlResolveRequiresResolution/resolvewith auth + empty body → 400, pinning body-validation-after-auth ordering.TestHitlPipelineIdValidationInvalidPipelineIdError).TestHitlHttpMethodEnforcementTestHitlMalformedJsonBodynullbody coerces correctly.TestHitlResolvePayloadEdgeCasesFalseresolution — pins which trip theif not resolutioncheck vs which fall through (and that dict/list don't 500 the json.dumps normalisation).TestHitlCancelOnUnknownDecision/cancelwith auth on missing decision → 404 envelope with pipeline id.TestHitlOversizedPayloadtest_deterministic_pipeline_id_is_syntactically_validpipeline-<8hex>so 404 assertions don't silently turn into 400 assertions.#2656 fix details
queue_decisionandresolve_decisionpreviously diddata = request.get_json() or {}thendata.get(...). When the body was syntactically-valid JSON but not an object (list / scalar),.getraisedAttributeErrorand the handler's genericexcept Exceptionmapper returned 500.queue_decisionis unauthenticated by design (agents queue decisions in-cluster), so a misbehaving agent could flood the orchestrator's error logs with stack traces.Both handlers now reject non-dict bodies with
400 Request body must be a JSON objectbefore any.getcall.nullstill coerces to{}viaor {}and falls through to the existing missing-field 400 branches.The 4 xfail-marked primitive-body cases in
TestHitlMalformedJsonBody::test_non_object_json_body_400now pass on real behavior, so the marks are dropped. New parametrized cases inorchestrator/tests/test_decisions_routes.py(test_non_object_json_body_returns_400,test_resolve_non_object_json_body_returns_400) pin the same invariant in the inner-loop suite.The issue's spot-check note (same
request.get_json() or {}+.getpattern inroutes/messages.py,routes/health.py,routes/containers.py,routes/anchors.py) is intentionally NOT swept in this PR — those routes span unrelated subsystems and warrant a separate follow-up.Coverage gaps (infra-blocked, tracked in #2657)
AWAITING_HUMAN(needs an agent callingregister_open_question).AWAITING_HUMANwithin Overseer container crashlooping at exit_code=1 after long-running pipelines (correlates with slice cascade) #2430's bypass deadline.restart_agent/ multiple sequential HITLs on a real pipeline.These need an ephemeral-pipeline fixture (could be unblocked by a lifecycle-authed test-seed endpoint that doesn't need a real repo + gateway round-trip) and pod-level LLM-trajectory injection (the
ScriptedProvidergap documented in #2474 and thefeedback_scripted_provider_pod_injectionmemo). #2657 lays out the phasing.Recovered #2474 attempt — three bugs avoided
The recovered commit
79589b2b8had atest_hitl_round_trip.pythat wouldn't have worked even if it had merged:/decisions/<id>(no/resolvesuffix) with{"action": "select", "selected": "opt-1"}— actual endpoint is/decisions/<id>/resolvewith{"resolution": "..."}and requires lifecycle auth.regression-<12hex>pipeline ids, whichstate_store.PIPELINE_ID_PATTERNrejects withInvalidPipelineIdError→ 400 instead of 404 — masking the assertion.gateway_urlinstead oforchestrator_url.The new conftest's
deterministic_pipeline_idhelper emitspipeline-<8hex>and a self-test pins the shape.Test plan
make test-integrationgreen on local k3s — 182 passed, 20 skipped (pre-existing legacy-docker skips), 4 xfailed (POST /api/v1/pipelines/<id>/decisions returns 500 on non-object JSON bodies #2656).orchestrator/tests/test_decisions_routes.pygreen (55 passed).make lintgreen.Test / aggregatepasses on this PR.