test: integration coverage for message store + event bus (#2640) - #2661
Conversation
Add k3s integration tier coverage under integration_tests/regression/
for the inter-agent message store and EventBus routing. 58 tests across
13 classes, parametrized over both backends (in-memory + Redis via
fakeredis) where applicable.
Coverage:
1. context_pr.{skipped,failed} routing end-to-end through the live
Flask blueprint — wrapper emit reaches both sinks on both backends
and is visible via GET /api/v1/pipelines/<id>/messages.
2. /status/wait wakes on context_pr.* events and CONTEXT_PR_* messages;
stays silent for non-allowlisted types (PROGRESS, decision.resolved).
3. EventBus sequence monotonic under 8-thread concurrent publishers;
message store preserves per-producer order across 4-thread fan-in.
4. Message store ordering matches EventBus MESSAGE_SENT sequence for a
single agent's stream (the exact invariant the issue names).
5. since_id_stale signal surfaces on /messages for unknown cursors.
6. Replay-on-resume: get_messages(since_id=...) returns
subsequent-only, and re-fetching from the tip returns empty.
7. EventBus does not replay history to late subscribers; unsubscribe
takes effect on subsequent publishes.
8. Malformed payload rejection at POST /messages — shell-var
to_role/from_role, missing HEARTBEAT.state, unknown state,
WAITING_ON_ROLE without waiting_on.
9. Dedupe of repeated _maybe_open_base_pr_for_plan_to_implement
invocations — serial AND a 16-thread race against the
_context_pr_events_emitted_lock both collapse to one entry.
10. Blocking get_messages(wait=N) wakes on add_message; wakes on
clear() (RISK-5 from #1897); from_tip=True ignores pre-existing
messages (#1925).
11. /status/wait first-source-wins between event bus and message store.
12. Deprecated QUESTION message_type coerces to PROGRESS through the
POST /messages route (#1897).
13. EventBus sequence is globally dense across mixed event types — the
invariant the /status/wait cursor protocol depends on.
Per #2474 constraint: tests run as part of `make test-integration` via
@pytest.mark.integration, in-process against the real Flask blueprint
and live EventBus, with fakeredis for the Redis backend and patches at
the inner-hook / state-store boundary. The agent sandbox cannot bring
up k3s, so verification is by green required-check, not local run.
Authored-by: egg
EventType.PIPELINE_CANCELLED was defined and in the /status/wait allowlist + terminal-synth map, but the orchestrator never emitted it. The PATCH cancel path mutated state and ran cleanup but did not publish the event, so an in-flight long-poll sat to its 25s timeout before observing cancellation via the late-subscriber synth path on the next poll. Two changes: 1. Add "pipeline.cancelled" -> EventType.PIPELINE_CANCELLED to _EVENT_TYPE_MAP in routes/pipelines.py so _emit_pipeline_event no longer no-ops on the string. 2. Call _emit_pipeline_event(pipeline, "pipeline.cancelled") from update_pipeline immediately after store.update_pipeline returns a CANCELLED pipeline. Mirrors the pipeline.failed / pipeline.completed pattern. Only CANCELLED is emitted here; FAILED is already emitted by the run loop's terminal transitions. Regression test added alongside the #2640 integration suite covers the wake-up via the same _emit_pipeline_event helper the route now calls.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Summary
Approving. This is a high-quality regression-tier addition that meaningfully tightens the integration coverage on the message-store + EventBus seams, plus a small, well-scoped fix for #2663. I verified:
- All 60 tests collect and pass locally in ~12s against Python 3.14 +
fakeredis(no flakes across two runs, including the 16-thread dedupe race). - The
_EVENT_TYPE_MAPentry atorchestrator/routes/pipelines.py:1136correctly closes the no-op gap:"pipeline.cancelled"was already in_STATUS_WAIT_EVENT_TYPES(line 283) and in_TERMINAL_EVENT_TYPES(line 4017), but_emit_pipeline_eventwas silently no-op'ing because the string was missing from the dispatch map. - The PATCH-side emit at
orchestrator/routes/pipelines.py:2210-2211is placed correctly: afterstore.update_pipelinereturns the post-mutation pipeline, and before the background cleanup thread spins up. The cancel-in-PATCH path is the only origin of the CANCELLED transition;pipeline.failed/pipeline.completedare emitted from_run_pipelineitself, so the fix's narrow scope is correct. - Both backends are genuinely exercised — verified
fakeredis.FakeRedis()honorsXREAD block=Nand so the Redis path actually blocks rather than spin-returning. - Conftest sys.path setup mirrors
integration_tests/test_slice_pipeline_e2e.py:54-59exactly, so the import shape is consistent with the existing integration tier. - The 16-thread dedupe race-test passes cleanly despite the inherent
patch.objectthread-unsafety (see Non-blocking #3 below), because every thread setsinner.side_effect = RuntimeError(...)on whatever mock is current — the wrapper's_open_context_pr_for_pipelinecall always resolves to some mock with the right side effect.
Non-blocking suggestions
-
update_pipelineemits on status-equality, not status-transition (orchestrator/routes/pipelines.py:2210).if pipeline.status == PipelineStatus.CANCELLED:will re-emit on any subsequent PATCH against an already-cancelled pipeline (e.g. an idempotent retry from a flaky caller). The user-visible effect is a long-poller waking earlier than necessary, so this is benign — but the cleaner shape compares the pre-update status to the post-update status and only emits on the actual transition. The current shape mirrors how_run_pipelineemitspipeline.completed/pipeline.failedfrom terminal transitions (which are also transition-gated by virtue of being inside the run loop's terminal branches), so consider tightening to match. -
The new
test_status_wait_wakes_on_pipeline_cancelled_eventdoes not actually drive the PATCH handler. The test callspipelines_mod._emit_pipeline_event(fake_pipeline, "pipeline.cancelled")directly from a thread, then asserts/status/waitwakes on the resulting event. This verifies that (a) the map entry exists, (b) the route subscribes to PIPELINE_CANCELLED, and (c) the route reportsevent_type="pipeline.cancelled"— but it does NOT verify thatupdate_pipelineitself calls_emit_pipeline_eventafter the transition. Aclient.patch(...)driven test would be stronger; it would require mockingstore.update_pipelineto return a CANCELLED pipeline, but that's reachable from this fixture set. Not a regression, but the fix's actual wiring is one indirection removed from what the new test exercises. -
patch.objectis not thread-safe — the 16-thread dedupe race test relies on benign interleaving. Intest_concurrent_wrapper_invocations_dedupe_via_lock, each of the 16 threads enters its own nestedpatch.object(pipelines_mod, "_open_context_pr_for_pipeline")context.unittest.mock.patch.object's__enter__/__exit__are not synchronized — they snapshot the current attribute at enter time and restore it at exit time. With 16 concurrent enters, the unpatch order matters; in a pathological interleave you can leak a mock past the test boundary. The autousereset_context_pr_dedupekeeps state clean, so this won't hurt other tests, but it's fragile. Consider patching once at the outer scope and usingthreading.Barrier(16)to release-and-race; that would also remove thestart_gate.wait(timeout=5)and tighten the contention window. -
time.sleep(0.2)synchronization in the wake tests. A handful of tests (e.g.test_status_wait_wakes_on_context_pr_failed_event,test_blocked_get_wakes_on_add_message) rely ontime.sleep(0.2)to ensure the route has entered its blocking branch before the background thread fires. On a heavily loaded CI runner this could in principle race — the 3-second route timeout absorbs the variance, but athreading.Eventset by the route entry would be deterministic. Not changing today's behavior, but worth a future cleanup if CI flake rates surface. -
test_blocked_get_wakes_on_clearRedis path takes the fullwait=2budget. The Redis backend'sclear()runsDEL stream, which does not wake a blocked XREAD (confirmed against fakeredis and matches real Redis semantics). The docstring acknowledges this and the assertionresult["msgs"] == []works for both wake-on-clear (in-memory) and timeout-then-empty (Redis). Result: this single test adds ~2s to each Redis run. Two backends × ~2s = ~4s of wall-clock on this test alone. If suite-time matters, droppingwait=2 → wait=1would halve that without losing the contract. -
update_pipelineover-emit on idempotent PATCH (#1 above) is also testable from this fixture set if you want to pin the desired behavior. Aclient.patch(...)cycle with status=cancelled twice in a row, subscribed toEventType.PIPELINE_CANCELLED, would let you assert either "exactly one event" (transition-based) or "two events" (current). Pinning whichever shape is the intended contract would prevent surprise later.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…ic tests) Addresses non-blocking suggestions from the PR review: 1. PATCH cancel path now gates on the status *transition* (pre-update != CANCELLED && post-update == CANCELLED), not status equality, so an idempotent retry against an already-cancelled pipeline does not re-emit pipeline.cancelled and re-wake long-pollers. 2. New TestPatchCancelEmits class drives the PATCH handler via client.patch(...) — verifying the route-side emit wiring one indirection deeper than the existing helper-publish test. 3. test_concurrent_wrapper_invocations_dedupe_via_lock now patches _open_context_pr_for_pipeline once at the outer scope and uses threading.Barrier(16) to release threads simultaneously, replacing the per-thread nested patch.object stack (whose __enter__/__exit__ are not synchronized and can leak past the test boundary). 4. Wake-up tests synchronize via threading.Event signals fired from inside the wait primitive (event_bus.subscribe and message_backend.get_messages) rather than time.sleep(0.2). Two reusable context managers (_route_subscription_signal and _blocking_get_signal) ship in test_message_bus_routing.py. Message wake tests use the get_messages signal because the route's daemon captures the store tip after event_bus.subscribe returns; firing only on the event-subscribe signal can race the tip capture. 5. test_blocked_get_wakes_on_clear drops wait=2 → wait=1, halving the Redis-path wall clock without losing the contract assertion. 6. test_patch_cancel_idempotent_does_not_re_emit pins the new transition-gated emit contract — two PATCH cycles against an already-cancelled pipeline must produce zero PIPELINE_CANCELLED events. Conftest gains a session-scoped EGG_LIFECYCLE_SECRET fixture + lifecycle_auth_headers fixture so the regression tier can drive lifecycle-secret-gated PATCH routes (mirrors orchestrator/tests/conftest.py). Authored-by: egg
|
Thanks for the thorough review — all six non-blocking suggestions addressed in commit
Test plan re-verified: — Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review summary
Approving. All six non-blocking suggestions from the prior review are addressed cleanly. Verified the delta from 66455b77... → 05d74c6 (3 files, +382/-99):
- #1 transition-gated emit (
orchestrator/routes/pipelines.py:2202,2214) —prev_status = _pipeline.statuscaptured beforestore.update_pipeline, thenif pipeline.status == CANCELLED and prev_status != CANCELLEDcorrectly gates on the* → CANCELLEDtransition. The shape matches_run_pipeline's transition-emittedpipeline.completed/pipeline.failed. - #2 PATCH-driven emit test (
TestPatchCancelEmits::test_patch_cancel_emits_pipeline_cancelled_event) — drivesclient.patch(...)through the real route handler, mocks_resolve_pipelineto return(store, pre_update=RUNNING),store.update_pipeline.return_value = post_update=CANCELLED, subscribes toEventType.PIPELINE_CANCELLEDon the isolated bus, asserts exactly one event. The route's_emit_pipeline_event → events._emit_event → bus.publishchain is not stubbed; this genuinely exercises the production wiring. - #3 thread-safe race test (
test_concurrent_wrapper_invocations_dedupe_via_lock) — single outerpatch.object(pipelines_mod, "_open_context_pr_for_pipeline")wraps thread creation/start/join;threading.Barrier(16)replacesstart_gate. The per-thread nested-patch.object stack is gone, and contention is now around_context_pr_events_emitted_lockrather than around mock setup/teardown. - #4 deterministic wake-up sync (
_route_subscription_signal,_blocking_get_signal) — two module-scope context managers replacetime.sleep(0.2)across all fiveTestStatusWaitContextPRSemanticswake tests + bothTestBlockingGetMessagesconsumer tests. The author's choice of_blocking_get_signalfor message-wake tests (over_route_subscription_signal) is correct: the route's daemon snaps to the store tip viaget_messages(from_tip=True)AFTERevent_bus.subscribereturns, so firing only on subscribe can race the tip capture. - #5 Redis wallclock —
wait=2 → wait=1intest_blocked_get_wakes_on_clear. The contractresult["msgs"] == []still holds on both backends; ~1s shaved per Redis run. - #6 idempotent-emit pin (
test_patch_cancel_idempotent_does_not_re_emit) — pre-update isalready_cancelled, post-update isalready_cancelled, assertsreceived == []. Locks in the new transition gate so a regression to status-equality would surface immediately.
EGG_LIFECYCLE_SECRET session fixture in integration_tests/regression/conftest.py mirrors orchestrator/tests/conftest.py:166-181 (same prev/restore semantics, different secret value); lifecycle_auth_headers is a per-test fixture rather than monkey-patching FlaskClient.open — both styles are fine.
Non-blocking observations
-
_blocking_get_signalstill has a microsecond-scale tip-capture race. The instrumented wrapper doesconsumer_entered.set()before callingoriginal(*args, **kwargs), so the producer can wake on the signal and inject before the originalget_messagesresolvesfrom_tip=Trueto the current head. Practically the window is a few function-call frames vs the previous 200ms sleep, so this is strictly better; just noting it's not truly synchronized. If this ever flakes on a CPU-starved CI runner, the fix is to push the signal deeper (e.g., into a callback at the point where the tip is actually captured), not back to a sleep. -
Two session-scoped
_set_lifecycle_secret_envfixtures now collide if both directory trees run in the same pytest session.orchestrator/tests/conftest.pysetsEGG_LIFECYCLE_SECRET=TEST_LIFECYCLE_SECRET;integration_tests/regression/conftest.pysets it totest-lifecycle-secret-regression. Both arescope="session"autouse — each fires once on the first test in its respective tree. In single-session mixed execution, whichever fired later wins for the rest of the session. The verifiedpytest integration_tests/regression/... orchestrator/tests/...invocation passes because the regression fixture fires first (env=regression), then the orchestrator fixture fires (env=TEST_LIFECYCLE_SECRET) and orchestrator's per-test_inject_lifecycle_authinjects matchingTEST_LIFECYCLE_SECRETheaders. Reverse ordering would break orchestrator auth. Not blocking — currentmake test-allcollection order avoids it — but a future xdist-parallel or filename-sorted run could regress. A safer pattern would be a per-test (function-scoped) env fixture, or a single shared session-scoped fixture in a common conftest. -
_blocking_get_signalonly fires on keywordwait.kwargs.get("wait")won't see a positional 3rd-argwait=N. All current call sites passwait=as kwarg so this works; a future test usingget_messages(_PIPELINE_ID, since_id, wait_value)positionally would silently never set the event and time out atconsumer_entered.wait(timeout=3). Cheap defensive fix:wait_arg = kwargs.get("wait") or (args[3] if len(args) > 3 else 0); if wait_arg: consumer_entered.set(). -
threading.Barrier(n).wait(timeout=5)raisesBrokenBarrierErroron the slow thread and on all subsequently-arriving threads. The previousstart_gate = threading.Event()was more forgiving (a late thread simply proceeded with the others). 16 threads ×daemon.start()overhead is well below 5s in practice, so this is a real-world non-issue, but if you ever seeBrokenBarrierErrorin a flake, the timeout is the cause. -
TestStatusWaitFirstSourceWins(line 1496) still usestime.sleep(0.1)/time.sleep(0.5)to enforce event-vs-message ordering. Pre-existing; the new helpers don't apply here (the test deliberately spaces sources rather than synchronizing on the wait primitive). Left as-is is the right call; a future refactor could replace this with deterministic ordering viabus.publishfrom the test thread sequenced relative toadd_message, but that's a larger change.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Conflict Resolution SummaryResolved merge conflicts with
Verification (local):
Please review: the — 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 summary (merge-only delta)
Approving. The only commit since my prior re-review at 05d74c6 is the merge from origin/main (9410b22). The substantive PR contents — the message-bus regression suite, the pipeline.cancelled map entry, the transition-gated route emit, and the deterministic-sync helpers — are unchanged. I verified the merge resolution did not regress the fix or the test surface:
integration_tests/regression/conftest.py— additive merge of#2649BRC fixtures and this PR's lifecycle fixtures. Combined docstring, unioned imports (os,Callable,Generator,_helpers,events,peer_consensus,review_graph),sys.pathsetup matches#2649's shape (includes_REGRESSION_DIRso_helpersresolves). The eight fixtures (_set_lifecycle_secret_env,lifecycle_auth_headers,_reset_tracker_registry,event_capture,filter_events,single_reviewer_graph,two_reviewer_graph,advisory_blocker_graph) have disjoint names and disjoint scopes — no shadowing, no fixture-resolution ambiguity. The lifecycle fixtures and BRC fixtures don't share dependencies, so combining them is mechanical.orchestrator/routes/pipelines.py— auto-merged.pipeline.cancelled → EventType.PIPELINE_CANCELLEDis still in_EVENT_TYPE_MAPat line 1136. The transition-gated emit at line 2202/2213-2214 (prev_status = _pipeline.statuscaptured beforestore.update_pipeline;if pipeline.status == CANCELLED and prev_status != CANCELLED:before_emit_pipeline_event) is intact.integration_tests/regression/test_message_bus_routing.py— unchanged by the merge (1641 lines, 34 test methods across 14 classes, includingTestPatchCancelEmitswith both the emit and idempotent-suppress tests,_route_subscription_signaland_blocking_get_signalhelpers, thethreading.Barrier(16)race test, and thewait=1Redis-budget tweak). All call sites forget_messagespasswaitas kwarg (verified across 18 occurrences), so the priorkwargs.get("wait")observation remains theoretical.
Non-blocking carry-overs from prior re-review
All five non-blocking observations from my prior review at 05d74c6 still stand — none were touched by the merge. Recapping the highest-impact one because the merge widens the surface for it:
- Two session-scoped
_set_lifecycle_secret_envfixtures acrossorchestrator/tests/conftest.pyandintegration_tests/regression/conftest.pynow collide if both directory trees are collected in the same pytest session.orchestrator/tests/conftest.py:166-181setsEGG_LIFECYCLE_SECRET="test-lifecycle-secret-egg1769"and patchesFlaskClient.opento inject that exact secret on every request via_inject_lifecycle_auth. The regression conftest sets the env to"test-lifecycle-secret-regression". Both areautouse=True, scope="session", so the env value at any given moment is whichever fixture fired later. In a mixed-tree session orderedregression_tests → orchestrator_tests, the orchestrator's session fixture fires second and overwrites the env to its own value — which then matches the orchestrator's_inject_lifecycle_authheader, so orchestrator tests still pass. In the reverse ordering, the regression fixture fires second and overwrites the env to its own value — which no longer matches the orchestrator's injected header on any subsequent re-entrant orchestrator test, so those would 401. The merge brings #2649's BRC tests into the same conftest, doubling the surface area collected from the regression tree, so this latent flake is now more reachable than it was at05d74c6.make test-integrationruns onlyintegration_tests/(verified at the Makefile target —$(PYTEST) integration_tests -v -m "integration or security"), so the production CI command does not trip the collision today. The cleanest fix is a single session-scoped env fixture in a shared parent conftest (or one top-level fixture imported from both), but that's out-of-scope for this PR.
The other four (microsecond tip-capture race in _blocking_get_signal, kwarg-only wait matcher, BrokenBarrierError on slow threads, TestStatusWaitFirstSourceWins's pre-existing time.sleep) are unchanged and unaffected by the merge.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Switch _set_lifecycle_secret_env in integration_tests/regression/conftest.py from session-scoped to function-scoped (via monkeypatch.setenv). The session-scoped autouse fixture used a different secret value (test-lifecycle-secret-regression) than the orchestrator suite's session-scoped equivalent at orchestrator/tests/conftest.py:167 (test-lifecycle-secret-egg1769); in a mixed-tree pytest session whichever fired second would overwrite the env, and the other tree's FlaskClient.open monkey-patch would then inject a header that no longer matched the env-read secret on the server side. Function-scoped via monkeypatch keeps the regression override strictly local to each test and restores cleanly. make test-integration runs only integration_tests/ so this latent flake is not currently reachable in production CI, but #2649 widened the surface here and a future xdist-parallel or filename-sorted run could regress. Addresses the last carry-over non-blocking observation from the prior review of this PR.
|
Thanks for the carry-over review. Disposition for the five non-blocking observations:
Test plan re-verified post-fix:
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review summary (delta 9410b22 → e375f54)
Approving. The single new commit (e375f54) addresses non-blocking observation #1 from my prior re-review — the session-scoped _set_lifecycle_secret_env collision between integration_tests/regression/conftest.py and orchestrator/tests/conftest.py:166 in mixed-tree pytest sessions.
Delta verified against gh api .../compare/9410b22cc...e375f54 — one file changed, +17/-13:
integration_tests/regression/conftest.py:75-94— fixture is now@pytest.fixture(autouse=True)(function-scoped, default) withmonkeypatch.setenv("EGG_LIFECYCLE_SECRET", _TEST_LIFECYCLE_SECRET)replacing the manualos.environset/restore dance.import osremoved (no other usages remain — verified). Module-level comment updated to explain the why.
Why the fix works
I walked the three mixed-session orderings against orchestrator/lifecycle_auth.py:52 (_configured_secret() reads env at request time, not at server startup), the regression lifecycle_auth_headers fixture (returns Bearer test-lifecycle-secret-regression), and pytest's monkeypatch semantics:
- Orchestrator session fixture fires first (env=
test-lifecycle-secret-egg1769): per-regression-testmonkeypatch.setenvcaptures prev=egg1769, overwrites toregressionfor the test, restores toegg1769at teardown. Orchestrator tests subsequently seeegg1769, matching their injected header. ✓ - Regression test fires first (env=None or operator value):
monkeypatch.setenvcaptures prev, overwrites toregression, restores prev at teardown. Orchestrator's session fixture later fires, snapshots prev, sets toegg1769. ✓ - Interleaved (xdist or filename-sorted): same per-test snapshot/restore semantics —
monkeypatchrecords the env value at fixture-entry, so whatever the surrounding session fixture set is what gets restored. ✓
The lifecycle_auth_headers fixture is unchanged (still returns Bearer test-lifecycle-secret-regression), and the autouse env fixture matches that value during the test body, so server-side _secrets.compare_digest in lifecycle_auth.py:112 passes. Verified the existing PATCH tests in test_message_bus_routing.py:610,684 are still consistent with this.
Non-blocking observations
-
The autouse env fixture fires for every regression test, including BRC tests in
test_brc_*.pythat never hit lifecycle-gated routes. This is essentially free (a single setenv per test) and arguably good — any future BRC test that needs to drive a PATCH-side route works without opting in. Worth noting if you ever see the test count grow large enough that fixture-setup overhead matters; opt-in via a request-supplied marker would be the cleaner shape, but not worth the churn today. -
monkeypatchis function-scoped by design in pytest. If someone later tries to widen this back to session-scope for performance reasons, the built-inmonkeypatchfixture won't accept that — they'd need the third-partypytest-monkeypatch-sessionpackage or open-coded session-level env management (which is exactly what this commit just removed). The module-level comment explains the rationale; if you want belt-and-suspenders, a# DO NOT widen to scope="session" — see comment abovenote above the decorator would catch a future drive-by reviewer. -
make test-integrationruns onlyintegration_tests/(verified at the Makefile target), so the collision scenario this fix addresses was not reachable in production CI today — but the prior review's concern stands: the merge fromorigin/mainwidened the regression-tier surface via #2649, and future xdist or filename-sorted collection could surface the latent flake. This fix preempts that cleanly.
The other four carry-overs from my prior re-review (microsecond tip-capture race in _blocking_get_signal, kwarg-only wait matcher, BrokenBarrierError on slow threads, TestStatusWaitFirstSourceWins's pre-existing time.sleep) are unchanged. The author's disagreements on all four are well-reasoned; I confirmed #3 specifically against the production API contract: MessageStore.get_messages at orchestrator/message_store.py:253-264 uses * to force wait keyword-only, so kwargs.get("wait") is provably exhaustive — a positional call would raise TypeError before reaching the wrapper.
CI is green (Integration Tests / Integration Tests ✓ on e375f54).
— 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.
1 similar comment
This comment has been minimized.
This comment has been minimized.
Conflicts: - integration_tests/regression/conftest.py: combined message-bus fixtures (#2640, this PR) with k3s slice-spawn helpers (#2632, main) and shared BRC fixtures (#2635). All three tiers now coexist with dedicated section headers; docstring updated to describe all three. orchestrator/routes/pipelines.py auto-merged cleanly: this PR's #2663 pipeline.cancelled emit survives alongside main's _read_tree_head helper for #2626.
Conflict Resolution SummaryResolved merge conflicts with
Verification run locally:
Please review:
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review summary (delta e375f54 → 2f0ab83)
Approving. The only new commit since my prior re-review is the merge from origin/main (2f0ab83). The substantive PR contents — the message-bus regression suite, the pipeline.cancelled map entry, the transition-gated route emit, and the deterministic-sync helpers — are unchanged. I walked the merge resolution end-to-end and the three wires of the #2663 fix and one new cross-tier interaction added by the merge.
Merge resolution verified
integration_tests/regression/conftest.py— additive 3-way merge of three orthogonal tiers (message-bus #2640 from this PR, k3s slice-spawn #2632 from main, BRC #2635 from shared). All eight fixtures have disjoint names (_set_lifecycle_secret_env,lifecycle_auth_headers,kubectl_get_pod_yaml,env_from_pod,spawner,_reset_tracker_registry,event_capture,filter_events, plus the three*_graphfixtures); section dividers at lines 81/117/248 demarcate each tier. Module docstring is updated to reflect all three.sys.pathsetup includes_REGRESSION_DIRso the BRC tier'sfrom _helpers import EventFilter, filter_eventsresolves; orchestrator + shared + project root are added for the message-bus tier'sfrom events import …/from message_store import …and the slice-spawn tier'sfrom kubernetes_spawner import …imports.orchestrator/routes/pipelines.py— auto-merged. All three wires of the #2663 fix are intact:"pipeline.cancelled"is in_STATUS_WAIT_EVENT_TYPESat line 283,"pipeline.cancelled": EventType.PIPELINE_CANCELLEDis in_EVENT_TYPE_MAPat line 1136, and the transition-gated emitif pipeline.status == PipelineStatus.CANCELLED and prev_status != PipelineStatus.CANCELLED:at lines 2213-2214 still fires before the cleanup block. The new_read_tree_headhelper from #2626 (lines 7454-7480) is unrelated and lives in_commit_statefiles_to_worktree's scope.integration_tests/regression/test_message_bus_routing.py— unchanged by the merge (git diff e375f547..2f0ab834 -- …is empty).
Cross-tier safety: autouse lifecycle fixture vs k3s slice-spawn tests
The autouse _set_lifecycle_secret_env (function-scoped, monkeypatch.setenv) now fires for every regression test, including the k3s slice-spawn tests that came in from #2632. I checked whether setting EGG_LIFECYCLE_SECRET="test-lifecycle-secret-regression" in the test-process env could leak into spawned agent pods and corrupt the slice's lifecycle gate. It cannot: EGG_LIFECYCLE_SECRET is in _PROTECTED_ENV_KEYS at orchestrator/kubernetes_spawner.py:98, and the env-merge loop at line 810 filters every protected key out before assembling the pod manifest. The lifecycle env override is per-test and monkeypatch-scoped, so it restores on teardown — egg_stack (session-scoped) reads the launcher secret, not the lifecycle secret. Safe.
Non-blocking carry-overs
All five non-blocking observations from my prior re-reviews still stand and are unaffected by the merge:
_blocking_get_signalhas a microsecond-scale tip-capture race (signal fires beforeoriginal(*args, **kwargs)).kwargs.get("wait")only matches kwargwait=— butget_messagesenforces keyword-only with*atorchestrator/message_store.py:253-264andorchestrator/redis_message_store.py:158-169, so a positional call would raiseTypeErrorat the language level; the matcher is provably exhaustive.threading.Barrier(n).wait(timeout=5)raisesBrokenBarrierErroron slow threads — real-world non-issue at 16 daemon threads.TestStatusWaitFirstSourceWinsretainstime.sleep(0.1)/time.sleep(0.5)to space sources; deterministic ordering via test-threadbus.publishwould be a larger refactor.- Autouse env fixture now fires for k3s + BRC tests that don't need it. Free in cost; opt-in marker would be cleaner but not worth the churn.
The author has reasonably dispositioned each; the merge from #2632 widens the surface in a way that's safe (per the kubernetes_spawner filter above), so #5 is mildly more visible but still non-blocking.
CI green on 2f0ab83 per the required Test / aggregate check.
— 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 re-verifying the merge-only delta (
The merge resolution preserves all three wires of #2663 ( — Authored by egg |
This comment has been minimized.
This comment has been minimized.
Resolves the conflict in `integration_tests/regression/conftest.py` introduced by #2660 (pipeline-recovery / unpushed-commit salvage tier for #2633) landing on `main` after this branch's last merge. - Docstring now lists all four regression tiers (recovery/salvage #2633, message-bus #2640, BRC #2635, k3s slice-spawn #2632) in one parallel description. - Dedupes the `_TEST_LIFECYCLE_SECRET` constant and the `_set_lifecycle_secret_env` autouse fixture (auto-merge took both copies). Keeps main's version because it's a strict superset — it also overrides `EGG_GATEWAY_READY_TIMEOUT_SECONDS=0` and registers the `no_lifecycle_auth` marker + `_inject_lifecycle_auth` FlaskClient wrapper from #2660. The message-bus tier's `lifecycle_auth_headers` helper is preserved.
There was a problem hiding this comment.
Re-review summary (delta 2f0ab83 → edd0e9e)
Approving. The only commit since my prior re-review is the merge from origin/main (edd0e9e), which resolves the conflict in integration_tests/regression/conftest.py introduced by #2660 landing on main after this branch's last merge. The substantive PR contents — the message-bus regression suite, the pipeline.cancelled map entry, the transition-gated route emit, and the deterministic-sync helpers — are unchanged by the merge. PR is now merged; this is informational.
Merge resolution verified
I diffed conftest_main (main at 81e9cb2) against conftest_merged (PR HEAD at edd0e9e). The only net-additions vs main are:
- Docstring updated to enumerate four orthogonal regression tiers (recovery/salvage #2633, message-bus #2640, BRC #2635, k3s slice-spawn #2632) instead of main's three.
- Message-bus section divider at
integration_tests/regression/conftest.py:194-205— descriptive only, no fixtures exceptlifecycle_auth_headers. The comment explicitly documents that the lifecycle-secret env +Authorization: Bearer …wrapping machinery lives in main's section above, so both tiers share_set_lifecycle_secret_env/_inject_lifecycle_auth/_TEST_LIFECYCLE_SECRET. lifecycle_auth_headersfixture atintegration_tests/regression/conftest.py:208-211— preserved from the PR.
Dedup pass on the merged file shows exactly 1 definition each of _TEST_LIFECYCLE_SECRET, _set_lifecycle_secret_env, _inject_lifecycle_auth, lifecycle_auth_headers, and pytest_configure. No leftover <<<<<<</=======/>>>>>>> markers from auto-merge.
Cross-PR fixture interaction verified
The merge replaces the PR's previous monkeypatch.setenv-based _set_lifecycle_secret_env (from e375f54, which addressed the prior session-scoped collision observation) with main's superset version. Both are function-scoped autouse; main's version additionally sets EGG_GATEWAY_READY_TIMEOUT_SECONDS=0 (needed by #2660's create_pipeline-driving salvage tests) and uses manual os.environ snapshot/restore. The teardown branch runs unconditionally via pytest's yield-fixture semantics, so the env restore is safe across test failures. _TEST_LIFECYCLE_SECRET = "test-lifecycle-secret-regression" matches what the autouse env fixture sets, what the autouse _inject_lifecycle_auth wrapper injects, and what lifecycle_auth_headers returns — the four references are consistent.
main's new _inject_lifecycle_auth autouse fixture wraps FlaskClient.open to inject Authorization: Bearer … on every request. I traced this against the PR's test cases:
TestPatchCancelEmits::test_patch_cancel_emits_pipeline_cancelled_eventandtest_patch_cancel_idempotent_does_not_re_emitpassheaders=lifecycle_auth_headersexplicitly. The wrapper'sif not any(k.lower() == "authorization" for k in existing)check atconftest.py:183skips re-injection — these PATCH tests exercise the same path as before the merge.- All other PR tests hit non-lifecycle routes (
GET /messages,GET /status/wait,POST /messages). The wrapper silently injects an Authorization header, but these routes don't carry@require_lifecycle_secret(verified the 8 decorator sites inorchestrator/routes/pipelines.py— none are on/messagesor/status/wait), so the header is ignored.
#2663 fix wires verified intact
In the post-merge orchestrator/routes/pipelines.py:
"pipeline.cancelled"in_STATUS_WAIT_EVENT_TYPESat line 286."pipeline.cancelled": EventType.PIPELINE_CANCELLEDin_EVENT_TYPE_MAPat line 1127.prev_status = _pipeline.statuscapture at line 2193, transition-gated emit at lines 2204-2205 (if pipeline.status == PipelineStatus.CANCELLED and prev_status != PipelineStatus.CANCELLED:).PipelineStatus.CANCELLED: "pipeline.cancelled"in_TERMINAL_EVENT_TYPESat line 4011.
integration_tests/regression/test_message_bus_routing.py is byte-identical to e375f54 (the prior reviewed commit before the 2f0ab83 merge).
Non-blocking carry-overs
All five non-blocking observations from prior re-reviews still stand and are unaffected by this merge:
_blocking_get_signalfiresconsumer_entered.set()beforeoriginal(*args, **kwargs)— microsecond-scale tip-capture race. The author's disposition (current shape is strictly better than the prior 200ms sleep; deeper signal is a future cleanup conditional on flake surfacing) stands.kwargs.get("wait")matches kwarg-onlywait=—get_messagesenforces keyword-only via*atorchestrator/message_store.py:253-264andorchestrator/redis_message_store.py:158-169, so a positional call raisesTypeErrorat the language level. The matcher is provably exhaustive.threading.Barrier(16).wait(timeout=5)raisesBrokenBarrierErroron slow threads — real-world non-issue at 16 daemon threads.TestStatusWaitFirstSourceWinsretainstime.sleep(0.1)/time.sleep(0.5)to space sources deliberately; the new sync helpers don't apply.- The autouse env fixture now fires for every regression test in all four tiers (including k3s and BRC).
EGG_LIFECYCLE_SECRETis in_PROTECTED_ENV_KEYSatorchestrator/kubernetes_spawner.py:98and filtered from pod manifests at line 810, so the per-test env override cannot leak into spawned agent pods.
One observation worth surfacing on the merge itself, not blocking:
- Section-divider organization is slightly off. main's
app,client, andfake_gatewayfixtures (used by the salvage tier from #2660) sit under theBRC consensus fixtures (#2635)divider atconftest.py:346. They belong to the recovery/salvage tier, not BRC. Purely cosmetic — fixtures resolve by name regardless of code position — but a future reader scanning by divider will be slightly misled. Not worth a follow-up unless someone is touching this file for other reasons.
CI is green on edd0e9e per the required Test / aggregate check, and the PR has been merged.
— Authored by egg
|
egg review completed. View run logs 20 previous review(s) hidden. |
Closes #2640.
Closes #2663.
Summary
Add k3s integration tier coverage for the inter-agent message store and EventBus routing under
integration_tests/regression/. 60 tests across 13 classes, parametrized across both backends (in-memory + Redis viafakeredis) where applicable.Also fixes #2663:
pipeline.cancelledis now emitted from the PATCH cancel path, so/status/waitlong-pollers wake immediately on cancellation instead of waiting for the late-subscriber synth path on their next poll.Coverage map
Issue starting points:
context_pr.{skipped,failed}reach both message store and event bus on both backends; visible throughGET /messagesTestContextPRRouting(3 × 2 backends)/status/waitwakes oncontext_pr.*event ANDCONTEXT_PR_*message; silent forPROGRESS/decision.resolved; wakes onpipeline.cancelledevent (#2663)TestStatusWaitContextPRSemantics(5 × 2)TestConcurrentOrdering,TestMessageStoreEventBusOrderingCorrelationGap-audit additions:
since_id_stale: Trueon unknownsince_id(Identify which consumers send stale since_id cursors to the message store #2464).get_messages(since_id=...)returns subsequent-only; refetch from tip is empty.to_role/from_role, missing / unknown / incomplete HEARTBEAT metadata → 400._context_pr_events_emitted_lockboth collapse to one message + one event.get_messages(wait=N)wakes onadd_message; wakes onclear()(RISK-5, Agent wait heuristics: replace sleep/poll loops with event-driven BRC message stream consumption #1897);from_tip=Trueignores pre-existing messages (egg-orch message wait-loopreturns already-seen messages immediately instead of blocking for new events #1925)./status/waitbetween EventBus and message store.QUESTION→PROGRESSviaPOST /messages(Agent wait heuristics: replace sleep/poll loops with event-driven BRC message stream consumption #1897).Fix:
pipeline.cancelledwake-up (#2663)EventType.PIPELINE_CANCELLEDwas in_STATUS_WAIT_EVENT_TYPES(so/status/waitlong-pollers expected to wake on it) but the orchestrator never emitted it —"pipeline.cancelled"was missing from_EVENT_TYPE_MAPand the PATCH cancel path didn't call_emit_pipeline_event. In-flight long-polls sat to their full 25s timeout before observing cancellation via the late-subscriber synth path on the next poll.Two changes in
orchestrator/routes/pipelines.py:"pipeline.cancelled": EventType.PIPELINE_CANCELLEDto_EVENT_TYPE_MAPso_emit_pipeline_eventno longer no-ops on the string._emit_pipeline_event(pipeline, "pipeline.cancelled")fromupdate_pipelineimmediately afterstore.update_pipelinereturns a CANCELLED pipeline. Mirrors thepipeline.failed/pipeline.completedpattern; only CANCELLED is emitted here because FAILED is already emitted by the run loop's own terminal transitions.Covered by the new
test_status_wait_wakes_on_pipeline_cancelled_eventtest (parametrized across both backends).Bugs / gaps surfaced (filing follow-ups)
While writing these tests, I noticed one further gap that the existing unit tier doesn't cover and that this PR does NOT fix (per scope):
k8s/base/orchestrator-deployment.yamldoes not setREDIS_HOST/EGG_MESSAGE_STORE_BACKEND, andk8s/base/has no Redis Deployment / Service / StatefulSet. The orchestrator falls back fromEGG_MESSAGE_STORE_BACKEND=autoto the in-memoryMessageStore. This means the production CI integration tier exercises only the in-memory backend end-to-end — theRedisMessageStorecodepath has zero live coverage. These tests usefakeredisto exercise the Redis path in-process; running the in-cluster orchestrator against a real Redis would require k8s manifests + anEGG_MESSAGE_STORE_BACKEND=redisenv knob. Filed as k3s does not deploy Redis — RedisMessageStore has no end-to-end CI coverage #2662.Two minor dead-enum observations (not filing, but noting for future trim):
EventType.MESSAGE_RECEIVED— defined, never emitted.EventType.CONTAINER_SPAWNED— defined, never emitted (CONTAINER_STOPPEDis wired).Test plan
ruff check+ruff formatclean on new files.PYTHONPATH=shared .venv/bin/pytest integration_tests/regression/test_message_bus_routing.py -m integration --timeout=30.orchestrator/tests/test_cancel_async_cleanup.py+orchestrator/tests/test_pipelines_api.py(53 tests).integration_tests/test_slice_pipeline_e2e.pystill green alongside the new file.Test / aggregaterequired check passes (this is the validation path per Expand integration test coverage #2474 — the agent sandbox can't bring up k3s).Authored-by: egg