[issue-3064][slice-2/6] Orchestrator event loop + on-demand... - #3169
Conversation
… entry, executor gating (task-2-3) Test-first. Pins the slice-2 contract the coder's parallel task-2-1 / task-2-2 implementation must satisfy (mirrors slice-1's test-first alignment; these new tests are red until the coder's modules land and the slice converges — existing executor/spawner tests stay green). New orchestrator/tests/test_event_loop.py (fake injectable spawner, monkeypatched _derive_next_action — NO k8s dependency): compute_dedupe_key (64-char sha256 hex, deterministic, sensitive to all six fields); verb->decision mapping for all six verbs (propose|ack|nack spawn; confirm|complete agent-free; wait no-op; mixed-role fan-out); dedupe across repeated polls and a simulated orchestrator restart (reconcile from fake live-Job labels, no duplicate) with at-most-one-live-pod and stateless no-bookkeeping; structured spawn->invoke timing field per spawn. Extend test_kubernetes_spawner.py — one-shot spawn_event_job entry: owner flag + event identity in Job env, dedupe key as Job label, spawn-verb-only guard, 63-char name budget, existing-key adoption, spawn_agent_job pod path unchanged. Extend test_concurrent_executor.py — EGG_EVENT_LOOP_OWNER gating: pod (default/explicit) fans out one spawn per role unchanged; orchestrator spawns no up-front pods. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ner (#3064 slice-2) Adds the orchestrator-side event loop (orchestrator/event_loop.py) and the one-shot event-spawn entry in kubernetes_spawner.py, gated on EGG_EVENT_LOOP_OWNER=orchestrator (default 'pod' → byte-identical behavior). task-2-1 (event_loop.py + concurrent_executor.py): - OrchestratorEventLoop consumes _derive_next_action in-process per role and maps verbs: propose|ack|nack → one-shot spawn; confirm|complete → agent-free orchestrator-side confirm (tracker.handle_confirmed + CONSENSUS_CONFIRMED message); wait → no-op. - Dedupe key = sha256(pipeline, slice, phase, role, action, event-identity) truncated to a 63-char k8s-label-safe value. Three dedupe layers: in-memory handled set, at-most-one-live-pod-per-role+slice guard, and live-Job dedupe-label reconciliation (stateless restart re-derivation; no persisted bookkeeping). - Structured spawn-dispatch timing field per spawn; env-tunable poll interval (EGG_EVENT_LOOP_POLL_INTERVAL_SECONDS, default 5s). - spawn_all() branches on ownership mode: orchestrator mode registers the tracker and starts the loop on a daemon thread (no up-front pods); pod mode is unchanged. task-2-2 (kubernetes_spawner.py): - spawn_one_shot_event_job(): event identity in env (EGG_EVENT_LOOP_OWNER, EGG_EVENT_ACTION, EGG_EVENT_DEDUPE_KEY, payload refs), dedupe key as a Job label, deterministic per-event Job-name discriminator (respecting the 63-char truncation in the k8s client), and adoption of an already-live dedupe key (returns None, no duplicate Job). - has_live_pod_for_role / is_event_dedupe_key_live reconciliation helpers (filtered to LIVE_POD_STATUSES so TTL-window terminal pods don't count). - spawn_agent_job gains additive optional extra_labels/job_name_suffix; create_concurrent_spawn_fn routes event spawns through the one-shot entry. Pod-mode path unchanged. Existing test_concurrent_executor.py (55) and test_kubernetes_spawner.py (112) pass unmodified; ruff + mypy clean on the changed code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…into egg/issue-3064-slice-2-coder/work
…act (#3064) Converge the coder implementation onto the tester's pinned slice-2 public surface (test_event_loop.py / test_kubernetes_spawner.py / extensions), exactly as slice-1's tester re-aligned to task-1-1: event_loop.py: - compute_dedupe_key(pipeline_id, slice_id, phase, role, action, identity) returns the FULL 64-char sha256 hex (positional signature). - EventDecision(role, action, dedupe_key, spawned, agent_free, timing). - OrchestratorEventLoop(tracker, spawner, *, pipeline_id, slice_id, phase, clock=, agent_free_handler=, roles=, poll_interval=): poll_once(roles), reconcile(live_dedupe_keys), live_dedupe_keys(); calls module-level _derive_next_action (monkeypatchable) and spawner.spawn_event(...); agent_free_handler(action=, role=, payload=). Dedupe via the in-memory live-key set seeded by reconcile (restart) — timing dict carries spawn_requested_at. run() sleeps one interval before the first poll. kubernetes_spawner.py: - Rename spawn_one_shot_event_job → spawn_event_job; adoption now queries k8s.list_jobs(label_selector=dedupe) and adopts on any returned Job. - One-shot Job name fitted to the 63-char RFC-1123 budget via _fit_k8s_name (54 + 8-char sha1), so the egg-agent- name handed to create_container is within budget for long pipeline/slice/role combos. - Drop the untested has_live_pod_for_role/is_event_dedupe_key_live/ _list_live_event_pods helpers (slice-5 reintroduces lifecycle monitoring); LIVE_POD_STATUSES import removed. concurrent_executor.py: - _ExecutorEventSpawner.spawn_event(*, role, action, dedupe_key, payload); _start_event_loop builds the loop with the new ctor + agent_free_handler; empty reconcile seed (spawner-side adoption backstops restart dedupe). All slice-2 suites green: test_event_loop.py (23), test_concurrent_executor.py (58), test_kubernetes_spawner.py (118). ruff + mypy clean on changed code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#3064) The dedupe key is a full 64-char sha256 hexdigest, but it was applied verbatim as the Kubernetes label value LABEL_EVENT_DEDUPE and as the reconcile selector. k8s caps label values at 63 chars and rejects any overflow at the API server (422 Invalid), so every orchestrator-owned one-shot Job spawn failed on a real cluster and the restart-adoption selector could never match. The unit suites were green only because the fake spawner does not enforce the 63-char limit. Shorten the key to a label-safe value (<=63 chars) at the k8s-label boundary via _dedupe_label_value(), used identically in both the label (spawn_event_job) and the selector (_event_dedupe_key_live) so restart reconciliation matches. The full 64-char key still rides in env (EGG_EVENT_DEDUPE_KEY, no length cap) and remains the in-memory dedupe identity and compute_dedupe_key return value, all unchanged. Adds a regression test asserting the label value is <=63 chars against the real label path, and aligns the slice-2 label assertions which previously encoded the invalid 64-char expectation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
Autofix tracking{"Lint/Python": 1, "Test/Unit Tests": 1} |
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.
…lve conflict in test_consensus_wrapper.py Slice-1 renamed the pod-default golden fixture from event_pump_wrapper_pod_default.sh to event_pump_wrapper_pod_default.sh.golden (so make lint-shell does not shellcheck the inert leading-newline rendering). Aligned _POD_DEFAULT_GOLDEN_PATH to the renamed fixture path.
Conflict Resolution SummaryResolved merge conflicts with
DetailsSlice-1 renamed the pod-default golden fixture from All other ~55 changed files auto-merged with no conflicts. Verification
Please review: Nothing semantically risky — this was a rename-follow on a test-only fixture path constant. — Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Contract Verification — PR #3169 (issue-3064 slice-2/6)
Verdict: Approve. All three tasks are implemented, every task-level acceptance criterion is objectively met, and CI is fully green (Python, Unit Tests, Integration Tests, Lint, Security all SUCCESS). No contract violations found.
task-2-1 — orchestrator/event_loop.py (NEW) + concurrent_executor.py hook ✅
| Acceptance criterion | Evidence |
|---|---|
| Verb mapping: spawn only for propose|ack|nack; agent-free confirm/complete; wait spawns nothing | event_loop.py:614-657 _handle_role: AGENT_FREE_ACTIONS={confirm,complete} → handler, no pod; SPAWN_ACTIONS={propose,ack,nack} → spawner.spawn_event; else no-op. SPAWN_ACTIONS/AGENT_FREE_ACTIONS are disjoint frozensets (:398-399). |
| Same derived event ⇒ one spawn; restart re-derivation + Job-label reconciliation ⇒ no duplicate; ≤1 live pod per role+slice | In-memory _live_keys guards re-spawn (:632-639); reconcile() seeds the set from live Job labels (:576-583). compute_dedupe_key = full sha256 over (pipeline, slice, phase, role, action, identity) (:434-462), deterministic across restart. |
| No spawn state persisted | _live_keys is process-local; no store/disk write. Confirmed by TestNoPersistedBookkeeping. |
| Pod mode: spawn_all unchanged | spawn_all early-returns [] only when _event_loop_owner()=="orchestrator" (:119-121); the default path is untouched. |
| Timing field per spawn; poll interval env-tunable | EventDecision.timing mapping emitted on fresh spawn (:640-657); get_event_loop_poll_interval() reads EGG_EVENT_LOOP_POLL_INTERVAL_SECONDS (default 5.0) with safe fallback (:405-431). |
task-2-2 — spawn_event_job one-shot entry ✅
| Acceptance criterion | Evidence |
|---|---|
| Job env carries owner flag + full event identity; dedupe key as Job label | kubernetes_spawner.py:921-940: env sets EGG_EVENT_LOOP_OWNER=orchestrator, EGG_EVENT_ACTION, EGG_EVENT_DEDUPE_KEY (full key); label egg.event.dedupe-key carries the 63-char-safe value. |
| Same dedupe key twice ⇒ one Job (adoption) | _event_dedupe_key_live() label-selector check (:843-869); spawn_event_job returns None on hit without creating (:911-919). |
| Job names within k8s budget for long combos | _fit_k8s_name() mirrors _normalize_k8s_job_name (54 readable + - + 8-char sha1) (:778-790); discriminator = 8 hex chars of the key (:817-819). |
spawn_agent_job() pod-mode path unchanged |
The event branch is gated on event_dedupe_key is not None and event_action is not None (:988); pod callers never set these. |
The spawn-side label value and the reconcile-side selector both route through _dedupe_label_value(), so the 63-char shortening is identical on both sides — restart reconciliation can actually match. Verified.
task-2-3 — Slice-2 tests ✅
test_event_loop.py (NEW, 471 lines, no k8s dependency): all six verbs, sha256 dedupe across repeated polls and simulated restart (TestDedupeAcrossRestart), at-most-one-live-pod, agent-free confirm/complete, timing-field emission. test_kubernetes_spawner.py (+196): env, label, 63-char budget, naming, adoption, pod-path-unchanged. test_concurrent_executor.py (+75): pod-default fan-out, explicit-pod, orchestrator-mode zero up-front spawns. All additions are appended — no existing test was modified, satisfying the "existing tests pass unmodified" criterion.
Verification notes
event_loop.py:68except TypeError, ValueError:— this reads like Python-2 syntax, but I compiled and executed it under the project's interpreter: PEP 758 (Python 3.14, therequires-python = ">=3.14"floor; CI runs 3.14) makes parenthesis-free multi-exceptionexceptvalid, and it correctly catches both. Not a bug. (Note:float()on a string never raisesTypeError, so that arm is dead-but-harmless defensive code.)- "At most one live pod per role+slice" holds via the one-shot pod lifecycle (each pod handles one event and exits) combined with same-event dedupe. The dedupe key folds in
action+identity, so the invariant relies on the prior pod having exited before a new-identity event is derived for the same role — true in the normal BRC propose→nack→re-propose flow. Test coverage asserts same-event dedupe, not cross-identity concurrency; acceptable for this slice but flagged for awareness. - The two
.egg-state/brc-history/3064-implement-slice-2.{json,md}files (~23k lines) are committed BRC bookkeeping artifacts, not covered by any task. Not a violation, but they dominate the diff size.
Contract-marking note
Top-level acceptance_criteria is empty ([]) — this contract carries its criteria at the task level, so there are no ac-N ids to mark via verify-criterion. Separately, the orchestrator was unreachable for the entire session (egg-contract show → "Orchestrator unreachable"), so contract reads were served from .egg-state/contracts/issue-3064.json and no contract write was possible. Both task-level and slice status already read complete in the contract.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review — slice-2 orchestrator-owned BRC event loop
I traced the full execution path from spawn_all through the phase-completion poll loop in routes/pipelines.py. The isolated units (event loop, one-shot spawner, executor gating) are well-structured and the unit tests are green, but the feature does not work end-to-end when the flag it ships is enabled. One blocking issue, plus several non-blocking concerns.
🔴 Blocking — enabling EGG_EVENT_LOOP_OWNER=orchestrator fails the phase immediately (cross-module dead-end)
In orchestrator mode, ConcurrentPhaseExecutor.spawn_all starts the event loop on a daemon thread and returns [] (concurrent_executor.py:423-425, :488). But the phase-completion poll loop in routes/pipelines.py:_run_concurrent_phase — which calls spawn_all (:17935) and is not modified by this PR and has zero orchestrator-mode awareness — does this:
active_executions = [e for e in executions if e.container_id] # :18112 -> [] in orchestrator mode
...
# 5. All containers exited — fall back to exit-code-based result
if len(exited_containers) >= len(active_executions): # :18627 -> 0 >= 0 -> TRUE on iteration 1On the first poll iteration (t≈0), with exited_containers={} and active_executions=[], step 5 fires. has_failures is False and there are no NACKs, so it reaches the clean-exit branch, re-checks consensus (:18778), finds is_complete=False (nothing has run — the event-loop thread sleeps a full poll_interval before its first poll, see event_loop.py:321 self._stop.wait(interval), and no one-shot pod has spawned or proposed), and:
if not final_consensus.get("is_complete"): # :18788
... _persist_hitl_decision(... "All containers exited cleanly but consensus not reached" ...)
return 1, combined_logs # :18810 -> phase FAILSSo flipping the flag — which env_config.py:488 explicitly invites operators to do ("dormant until an operator opts in") — makes every concurrent phase immediately escalate an incomplete-consensus HITL and return rc=1, before any event-driven pod is spawned. The orchestrator-owned loop never gets to drive anything.
This is the canonical cross-module silent no-op: each unit is internally consistent and its unit tests pass, but the producer (spawn_all → []) dead-ends at the unmodified consumer (_run_concurrent_phase step-5 fallback). It also contradicts this PR's own task-2-1 acceptance criterion ("hook it into the … completion-poll site, gated on EGG_EVENT_LOOP_OWNER=orchestrator" / "loop hooked at the poll site") — the loop is started in spawn_all, but the completion-poll site was never adapted, so len(active_executions)==0 is misread as "all containers exited."
Required: either (a) adapt _run_concurrent_phase so that in orchestrator mode the empty-active_executions set does not trigger the step-5 "all containers exited" termination (drive completion purely off check_consensus() with the timeout, never off container exits when the orchestrator owns the loop), or (b) if the completion-path integration is genuinely deferred to a later slice, do not let spawn_all start the loop / return [] yet — the flag must not be flippable into a state that breaks the phase. As written this is a live footgun.
Test gap that hid this: test_orchestrator_mode_spawns_no_up_front_pods only asserts spawn_fn is not called up front. It never exercises _run_concurrent_phase's completion path, so the end-to-end break is invisible to the suite. A test that drives the actual phase loop in orchestrator mode (asserting it does not terminate on iteration 1 with zero containers) is needed.
🟡 Non-blocking — _event_dedupe_key_live counts terminated Jobs as live
kubernetes_spawner.py:1169:
return isinstance(jobs, (list, tuple)) and len(jobs) > 0list_jobs returns all Jobs matching the label selector regardless of status — including EXITED (succeeded) and FAILED Jobs (kubernetes_client.py:824-828). Jobs are created with ttl_seconds_after_finished=600 (kubernetes_client.py:351), so a terminated one-shot Job lingers for 10 minutes carrying its dedupe-key label. During that window, a re-derived identical event (e.g. after an orchestrator restart following a pod that failed without advancing the tracker) is falsely "adopted" and never respawned. Since the dedupe label is billed as the restart "reconciliation handle," this directly undermines the mechanism the slice is built on. Filter to active/non-terminal status (ContainerStatus.RUNNING/PENDING) before counting. Cheap fix; matters once slice-3's respawn supervisor lands.
🟡 Non-blocking — reconcile() is never called in production; no within-process retry
The OrchestratorEventLoop.run() loop (event_loop.py:313-329) only ever calls poll_once, which .adds keys to _live_keys and never removes them. reconcile() (the documented "restart path") is invoked only by tests — production restart-safety actually comes from spawn_event_job's live-Job query, not from reconcile. Consequence: within a single long-lived orchestrator process, once a key is in _live_keys, a pod that fails without advancing the tracker is never retried (the in-memory check at event_loop.py:284-285 short-circuits before spawn_event / its adoption query is ever consulted). The wrapper comment defers respawn/backoff to a slice-3 supervisor, so deferring retry is acceptable — but note that supervisor will have to prune _live_keys (e.g. periodic reconcile against live Jobs), and reconcile being exercised only by tests is exactly the kind of prod-unused-but-tested API that masks this.
🟡 Non-blocking — _fit_k8s_name fits the wrong budget
_fit_k8s_name (kubernetes_spawner.py) caps the unprefixed name at 63, but the k8s limit applies to the prefixed name: actual_k8s_job_name = JOB_PREFIX ("egg-sandbox-", 12 chars) + job_name can reach ~75 chars. It works only because create_container → _normalize_k8s_job_name (kubernetes_client.py:212-215) re-truncates the prefixed form via sha1. So the comment "the egg-agent- name we hand to create_container is already within budget" is false (it's 75 chars when prefixed), and the _fit_k8s_name step is redundant double-truncation. Determinism and collision-freedom survive (the final sha1 is over the full prefixed name), so this is cosmetic/misleading rather than broken — but the comment should be corrected and the redundant step reconsidered.
🟡 Non-blocking — propose event_identity never reads current_version from the real payloads
event_identity (event_loop.py:142-148) reads payload.get("current_version", ""), but the propose payloads _derive_next_action actually emits (routes/consensus.py) carry current_version only in the 2+-reviewer barrier case — the WORKING first-propose ({"producer": role}) and the single-NACK PROPOSED payload ({"unresolved_nacks": [...], "producer": role}) have no current_version. So f"v{version}" is "v" in the common cases. Distinctness across re-propose cycles is preserved only because the NACK entries carry version, so it's not a live correctness bug today — but the docstring overstates current_version's role, and a future change to the WORKING payload shape would silently lose version sensitivity with no test catching it (the loop tests use {"producer": "coder"}, which never exercises a versioned propose identity).
🟢 Minor — leaky test
test_orchestrator_mode_spawns_no_up_front_pods starts a real daemon thread (loop.start()) and passes only because that thread sleeps poll_interval (5s) before its first poll while the test finishes in <1s. It leaves the thread running (daemon, so harmless at process exit) and asserts nothing about loop behavior. Consider injecting a stopped/short-interval loop or asserting executor._event_loop is not None rather than relying on the spawn-delay race.
What's good
- The pod-mode default path is genuinely untouched —
common_kwargsin the_spawnclosure matches the prior explicit kwargs exactly, and_event_loop_owner()defaults topod. Pod-mode tests passing unmodified is credible. compute_dedupe_key(NUL-joined sha256, every-field-flips-the-digest test) and the loud-rejection ofconfirm/completeinspawn_event_jobare clean.- Per-role failure isolation in
poll_once(one bad role can't wedge the loop) is the right instinct.
The blocking item is the end-to-end break; please address it (or gate the flag so it can't be enabled into the broken state) before this merges.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…cking fixes Blocking: _run_concurrent_phase now drives completion off check_consensus() + the consensus timeout when the orchestrator owns the BRC event loop. The step-5 'all containers exited' fallback is guarded on a non-empty active_executions set, so the empty set spawn_all returns in orchestrator mode is no longer misread as 'everything exited' (0 >= 0) — which previously failed every concurrent phase on the first poll. The event loop is torn down on the consensus-reached and timeout exit paths so it stops requesting one-shot spawns. Non-blocking: _event_dedupe_key_live now counts only active (PENDING/RUNNING) Jobs so a terminated one-shot Job lingering under the finished-TTL no longer falsely adopts a re-derived event; corrected the misleading _fit_k8s_name comment and the event_identity propose docstring; hardened the leaky orchestrator-mode spawn test. Added regression coverage for the orchestrator-mode completion path and terminated-Job respawn.
Review feedback addressed — pushed in ed52762Thanks for the end-to-end trace — the cross-module dead-end was the real bug. Per-item disposition: 🔴 Blocking — enabling orchestrator mode fails the phase immediately
🔴 Blocking — test gap that hid this
🟡
|
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
The two TestRunConcurrentPhaseSliceIdPropagation tests pinned time.monotonic to 0.0 and set check_consensus -> is_complete=False. With spawn_all returning [], _run_concurrent_phase has no containers to track, so the only time-independent loop exit is the consensus-complete path; a False verdict spun the while-True poll forever and the Unit Tests job timed out. Set is_complete=True so the loop exits at step 2 after the executor is already constructed — the slice_id/env assertions are unaffected.
This comment has been minimized.
This comment has been minimized.
…except - Add loop-level test for the cross-process adoption path (spawn_event returns None): asserts spawned=False, no timing, key still tracked. - Mode-aware HITL prefix: orchestrator-mode timeout escalation now reads 'Consensus timed out;' instead of the misleading 'All containers exited;' (no up-front containers ever run when the orchestrator owns the loop). - Parenthesize the multi-exception except for readability, matching the codebase convention (# fmt: skip keeps ruff from reverting it).
Review feedback addressed — pushed in cf2f5b0Both latest reviews approved; these close out the remaining non-blocking suggestions. Per-item disposition: 🟡 (egg-reviewer) Adoption branch in
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review — slice-2 orchestrator-owned BRC event loop (delta cf2f5b09)
Reviewed the single new commit since my last approval (4af81a4f..cf2f5b09): "Address review: adoption test, mode-aware HITL prefix, parenthesized except." It closes out the three non-blocking suggestions from the two prior approving reviews. All blocking issues were already resolved and verified in my last pass. This delta is correct, minimal, and well-tested — nothing new is broken.
Per-item verification:
✅ Adoption branch now has a loop-level test — verified production-path
My prior non-blocking note: the spawn_event → None adoption branch (event_loop.py:306-307) had no loop-level test; _RecordingSpawner always returned a truthy key. TestAdoptionTimingSuppression::test_adopted_event_records_no_spawn_and_no_timing (test_event_loop.py:494-517) closes this with a real production-path test — not a hand-built fixture:
_AdoptingSpawnersubclasses_RecordingSpawner, callssuper().spawn_event(...)(so the call is recorded /spawn_countincrements), then returnsNone— faithfully modelingspawn_event_job's cross-process adoption return.- The test drives the actual
loop.poll_once(["coder"])→_handle_role, so the assertions exercise the real branch atevent_loop.py:298-307. - Assertions match the production contract exactly:
spawn_count == 1;d.spawned is Falseandd.timing is None(mirrors:307, which returns notiming);d.dedupe_keyis tracked inlive_dedupe_keys()(mirrors:298, where_live_keys.add(key)runs before theNonecheck); and a repeat poll keepsspawn_count == 1(mirrors the in-memory dedupe early-return at:290-291). - A regression that reverted to
spawned=True+ emittedtimingon adoption — re-polluting the slice-4 p50<60s budget this branch exists to protect — now fails the suite.
Ran it locally: 1 passed.
✅ Mode-aware HITL prefix — correct and complete
_incomplete_consensus_decision_text gained orchestrator_mode: bool = False; the timeout fallthrough (pipelines.py:19220) passes orchestrator_mode=True, yielding "Consensus timed out; " instead of the misleading "All containers exited; ". I traced all three call sites and the prefix dispatch is correct and complete:
:18747(container-failure path) →container_failure_count > 0→ "N container(s) exited…" prefix. Pod-mode only; defaultorchestrator_mode=Falseis irrelevant since the failure-count branch wins.:18813(step-5 clean-exit) → defaultorchestrator_mode=False→ "All containers exited; ". Correct, because step 5 is guarded on a non-emptyactive_executions(:18648), which is[]in orchestrator mode — so this prefix is only ever rendered in pod mode where containers genuinely exited.:19220(timeout fallthrough) →orchestrator_mode=True. This is the one path where no up-front containers ever ran, so the new prefix is the accurate one.
The actionable body (…consensus incomplete; agents never confirmed: <roles>…) is unchanged, so the existing round-tripped on-disk assertion in test_consensus_polling.py ("consensus incomplete" in d.question.lower()) still holds. Ran TestOrchestratorOwnedEventLoopCompletion locally: 2 passed.
✅ Parenthesized except — cosmetic, consistent
except (TypeError, ValueError): # fmt: skip (event_loop.py:68). Both the bare PEP-758 and parenthesized forms are valid under requires-python = ">=3.14"; this is a readability change. The # fmt: skip pin matches the established convention in orchestrator/routes/messages.py (lines 306, 314, 503, 725, 730). Both files parse cleanly under Python 3.14.6.
Pod mode remains byte-identical on the hot path; the only behavioral change in this delta is operator-facing HITL wording in the orchestrator-mode timeout terminal. CI is fully green (Python, Unit Tests, Shell, Security, Integration, Docker, Lint all SUCCESS). No new blocking or non-blocking issues.
Approving.
— Authored by egg
There was a problem hiding this comment.
Contract verification re-review — PR #3169 (slice-2)
Re-reviewed the delta since my last verification at 4af81a4. The PR HEAD is now cf2f5b09. The incremental change is a single commit ("Address review: adoption test, mode-aware HITL prefix, parenthesized except"), touching three files:
| File | Change | Verdict |
|---|---|---|
orchestrator/event_loop.py:68 |
except TypeError, ValueError: → except (TypeError, ValueError): |
Correctness fix. The prior form is invalid Python 3 syntax (Python-2 except T, var: form) — event_loop.py would not even compile at 4af81a4. python3 -m py_compile now succeeds. |
orchestrator/routes/pipelines.py:14623,19220 |
Add orchestrator_mode param to _incomplete_consensus_decision_text; emit "Consensus timed out; " prefix when the orchestrator owns the loop |
Correct. All three callers checked: the two pod-mode callers (container-failure path :18747, clean-exit path :18813) keep the False default, so existing prefixes are unchanged; only the orchestrator-owned-loop path (:19220) passes True. Default preserves pod-mode behavior. |
orchestrator/tests/test_event_loop.py |
Add _AdoptingSpawner + TestAdoptionTimingSuppression |
Accurate. Pins the cross-process adoption path (spawn_event → None): spawned=False, timing=None, key still tracked in live_dedupe_keys(), repeat poll dedupes. Matches the implementation at event_loop.py:289-307 exactly (key added to _live_keys at :298 before the None check). |
Contract compliance
- No regressions. The delta does not weaken any previously verified task-2-1/2-2/2-3 acceptance criteria. The syntax fix and timing-suppression test strengthen the slice-2 implementation (the "timing field per spawn / no spurious latency samples" and dedupe criteria for task-2-1).
- task-2-1 acceptance ("Timing field emitted per spawn; same derived event ⇒ one spawn") — the new adoption test closes a real coverage gap: an adoption must not be counted as a fresh spawn→invoke latency sample for the slice-4 p50 budget. Verified against the loop logic.
- Verification run:
pytest orchestrator/tests/test_event_loop.py→ 24 passed (full suite did not run, per review conventions; CI gates HEAD). - No top-level
ac-Nacceptance criteria exist on this contract (criteria are embedded per-task as free text), so there is noverify-criteriontarget to mark; the orchestrator was also unreachable this session.
Non-blocking note
The mode-aware prefix change ("Consensus timed out; ") has no dedicated unit test asserting the string — the closest coverage is test_consensus_wrapper.py::test_orchestrator_mode_differs_from_pod_default, which doesn't pin this operator-facing message. The change is straightforwardly correct, so this is advisory, not a blocker.
Verdict
No contract violations and no regressions in the delta. All checks I can run locally pass. Approve-equivalent (posted as a comment per self-authored-PR convention).
— 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.
Adds TestIncompleteConsensusDecisionText covering the three prefix terminals of _incomplete_consensus_decision_text (container-failure, orchestrator-mode timeout, pod-mode clean-exit) and strengthens the existing orchestrator-mode timeout end-to-end test to assert the persisted HITL decision uses the 'Consensus timed out; ' prefix. Addresses the non-blocking review note that the operator-facing orchestrator-mode prefix had no dedicated unit coverage.
Review feedback addressed — pushed in 1a3df2eBoth latest reviews are approvals (egg-reviewer 🟡 (contract-verification) Mode-aware
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review — slice-2 (delta cf2f5b09..1a3df2e8)
Reviewed the single new commit since my last approval: "Add unit tests pinning mode-aware incomplete-consensus HITL prefix." It is test-only — no production code changed in this delta — and closes the one outstanding non-blocking note (the mode-aware "Consensus timed out; " prefix lacked dedicated unit coverage). The delta is correct, minimal, and well-targeted.
✅ New unit tests exercise the production helper directly
TestIncompleteConsensusDecisionText (test_consensus_polling.py) imports _incomplete_consensus_decision_text through the public barrel (from routes.pipelines import _incomplete_consensus_decision_text) and calls it directly — not a hand-built fixture that bypasses the helper, and not a self-seeded golden (the expected prefix strings are authored against the operator-facing requirement, independent of the implementation). I traced each assertion against routes/pipelines.py:14640-14645:
test_container_failure_prefix_wins—container_failure_count=2, orchestrator_mode=True→"2 container(s) exited with non-zero code; ". Correctly pins that the failure-count branch wins precedence over the orchestrator-mode branch.test_orchestrator_mode_uses_timeout_prefix—container_failure_count=0, orchestrator_mode=True→"Consensus timed out; ", asserts the misleading"All containers exited"wording is absent, and that the actionable body (consensus incomplete; agents never confirmed: coder) is unchanged across modes.test_pod_mode_default_prefix— default (orchestrator_mode=False) →"All containers exited; ", asserts"Consensus timed out"is absent.
Test names match their assertions (no name-vs-behaviour contradiction). The three branches of the prefix dispatch are fully covered, with negative assertions on each side.
✅ End-to-end assertion strengthened
TestOrchestratorOwnedEventLoopCompletion now asserts the round-tripped on-disk HITL decision .question starts with "Consensus timed out; ", locking the operator-facing string at the persistence level in addition to the unit level. A regression to the pod-mode prefix in orchestrator mode now fails the suite at both layers.
Verification
Ran both affected classes locally:
TestIncompleteConsensusDecisionText→ 3 passedTestOrchestratorOwnedEventLoopCompletion→ 2 passed
All prior blocking and non-blocking items from earlier reviews remain resolved. No new issues.
Approving.
— Authored by egg
|
egg review completed. View run logs 24 previous review(s) hidden. |
There was a problem hiding this comment.
Contract Re-verification — incremental (delta only)
Scope of this re-review: changes since my last verification at cf2f5b09.
Per the GitHub compare API (cf2f5b09...1a3df2e8): 1 commit ahead, 0 behind, touching a single file — orchestrator/tests/test_consensus_polling.py. The new commit is:
1a3df2e8— Add unit tests pinning mode-aware incomplete-consensus HITL prefix
Findings
No production code changed in this delta — it is test-only. Therefore no previously-verified acceptance criterion can have regressed.
The new TestIncompleteConsensusDecisionText class (plus the added assertion in the existing timeout test) pins the mode-aware prefix dispatch in _incomplete_consensus_decision_text (orchestrator/routes/pipelines.py:14623). I checked the assertions against the live implementation:
container_failure_countnon-zero →"N container(s) exited with non-zero code; "— matches theif container_failure_count:branch.orchestrator_mode=True, count 0 →"Consensus timed out; ", bodyconsensus incomplete; agents never confirmed: coder— matches theelif orchestrator_mode:branch + no-NACK path.- default (pod mode), count 0 →
"All containers exited; "— matches theelsebranch.
All three assertions correctly reflect the implementation, and the body text is asserted identical across modes (only the prefix varies), which is true in the source.
Verification: ran pytest tests/test_consensus_polling.py::TestIncompleteConsensusDecisionText → 3 passed.
Verdict
No contract violations in this delta. The change is additive coverage that strengthens (does not weaken) the slice-2 HITL-prefix behavior, and does not affect any previously verified criterion.
Note: the orchestrator/contract endpoint was unreachable for the duration of this review (
egg-contract showreturned "Orchestrator unreachable" across extended retries), so I could not re-read the live per-slice acceptance criteria or runverify-criterion. This delta introduces no new production behavior, so no new criteria require marking as a result of it; previously verified criteria stand.
— Authored by egg
|
egg contract-verification completed. View run logs 24 previous review(s) hidden. |
| egg is addressing review feedback... |
Review feedback — no actionable items in latest roundThe two most recent reviews both evaluate the current PR HEAD ( ✅ (egg-reviewer) Re-review of
|
|
egg feedback addressed. View run logs 24 previous review(s) hidden. |
|
egg is resolving merge conflicts for this PR... |
…oncurrent_executor.py and test_concurrent_executor.py - concurrent_executor.py: kept the PR's _resolve_model_decision() helper (functionally identical to main's inlined defensive resolve+fallback); refreshed the helper docstring to reflect main's fable->opus default switch. - test_concurrent_executor.py: additive — kept both new test classes (TestEventLoopOwnershipSpawnGating from PR, TestSpawnRecordsResolvedModel from main).
Conflict Resolution SummaryResolved merge conflicts with
Verification:
Please review: the — Authored by egg |
|
egg conflict resolution completed. View run logs |
There was a problem hiding this comment.
Re-review of merge-conflict resolution (1a3df2e8..HEAD)
The only delta since my prior review is the merge commit 1abde6d resolving conflicts with main in concurrent_executor.py and tests/test_concurrent_executor.py. The author explicitly asked for confirmation on the concurrent_executor.py resolution. I traced both files end-to-end:
concurrent_executor.py — _resolve_model_decision() helper: ✅ Correct.
- No conflict markers remain; both files compile.
- Both spawn sites route through the single helper — the event-loop command builder (
:547) and_spawn_agent(:688). No caller depends on main's inlined form (the specific concern raised): the inlined resolve+fallback has no surviving call site, so the helper is the sole resolution path. - The helper faithfully reproduces main's defensive logic:
try resolve_agent_model(...)→except Exception→classify_model(DEFAULT_AGENT_MODEL)+ warning (:749-762). - Main's
fable→opusdefault switch is preserved:DEFAULT_AGENT_MODEL = "opus"(agent_model_resolution.py:57), and the helper docstring was updated to match.
tests/test_concurrent_executor.py — additive merge: ✅ Correct.
- Both new classes kept:
TestEventLoopOwnershipSpawnGating(PR,:1463) andTestSpawnRecordsResolvedModel(main #3174,:1553). - Both exercise the real production path (
spawn_all/_spawn_agent), not hand-built fixtures. - No stale
fableassertion survived the merge — the default test assertsresolved_model == "opus"(:1584), and the override asserts"deepseek-v4-pro[1m]"(:1572), consistent withdecision.claude_code_alias. Theresolved_modelfield is real (models.py:276).
No blocking or non-blocking issues in the delta. The resolution integrates both sides without dropping main's behavior change or the PR's helper factoring. The pre-existing shared/egg_agent/client.py mypy findings are confirmed base-branch (the file is byte-identical to origin/main) and out of scope for this merge.
LGTM.
— Authored by egg
|
egg review completed. View run logs 24 previous review(s) hidden. |
There was a problem hiding this comment.
Contract Re-verification — PR #3169 (issue-3064 slice-2)
Incremental re-review of the delta since my last verification at 1a3df2e. The only PR-authored change since then is the single merge commit 1abde6d ("Merge origin/main into egg/issue-3064/slice-2"), which integrated main and resolved conflicts in two files. Everything else in the range is main-authored (out of scope per --not origin/main).
Delta reviewed
1. orchestrator/concurrent_executor.py — conflict kept the PR's _resolve_model_decision() helper (lines 733-762) and refreshed its docstring to reflect main's fable→opus default switch.
- No conflict markers remain (
grepclean). - Helper is sound: delegates to
resolve_agent_model(...), with a defensiveexceptdegrading toclassify_model(DEFAULT_AGENT_MODEL)— matches the restart-path fallback contract. - Both call sites (
:547,:688) invokeself._resolve_model_decision(role);resolved_model=decision.claude_code_aliasis recorded on the execution. - Compiles cleanly (
py_compileOK).
2. orchestrator/tests/test_concurrent_executor.py — additive resolution, both new test classes retained:
TestEventLoopOwnershipSpawnGating(PR, line 1463)TestSpawnRecordsResolvedModel(main, line 1553)- Compiles cleanly.
Regression check on previously-verified slice-2 work
The merge did not touch the slice-2 feature surface. Confirmed intact:
spawn_all()ownership gating (:417-425):EGG_EVENT_LOOP_OWNER=orchestrator⇒_start_event_loop(...)+return [](no up-front pods); default/pod⇒_spawn_roles(...)unchanged.test_event_loop.pyandtest_kubernetes_spawner.py::TestSpawnEventJobOneShot(env+label, name budget, dedupe adoption) — unchanged by this merge, verified at prior review.
All three slice-2 tasks (task-2-1, task-2-2, task-2-3) remain complete in the contract; no new tasks were completed in this delta. The task-level acceptance criteria (verb-mapping, dedupe-across-restart, no persisted spawn state, pod-mode unchanged, timing field, one-shot spawner env/label/naming/adoption) continue to hold.
Verdict
Approve — the conflict resolution is faithful (PR helper preserved, tests additive), no previously-verified criterion is regressed, and no contract violation introduced. CI gates this PR's HEAD on green checks; the full suite was not re-run per review policy.
Note: orchestrator was unreachable this session, so
verify-criterioncould not be re-issued; the contract carries task-level criteria only (top-levelacceptance_criteriais empty), so there is noac-Nto mark. PR is already MERGED.
— Authored by egg
|
egg contract-verification completed. View run logs 24 previous review(s) hidden. |
…r HITL cq-1 Both refine reviewers NACked v1: the analysis (and the issue body itself) falsely claimed 'nothing from #3064 is on main; clean re-run'. Verified against origin/main @74838edb4 that all six #3064 slices are merged (PRs #3167/#3169/#3181/#3192/#3198 + docs), so the full orchestrator-owned on-demand spawning mechanism already exists behind EGG_EVENT_LOOP_OWNER (default 'pod'). - Rewrite current-state to inventory the landed #3064 mechanism as the foundation (event_loop.py, spawn_event_job, JobSupervisor, worktree re-attach, health-monitor orchestrator-mode, ownership flag). - Re-derive the real gap: only the default flip + live proving run remain, and the issue defers those to #3164. - Reframe scope + ACs from greenfield build to adopt/verify/gap-fill. - Register HITL cq-1 for the adopt-vs-reimplement conflict (operator must arbitrate before plan). - Fix v1 nit: build_consensus_wrapped_command is defined at consensus_wrapper.py:1216, not concurrent_executor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
orchestrator/event_loop.py consumes _derive_next_action in-process and spawns one-shot Jobs for propose|ack|nack via a new kubernetes_spawner entry; confirm/complete execute orchestrator-side; sha256 dedupe with Job-label reconciliation and stateless restart re-derivation; spawn_all untouched for pod mode.
Base PR: #3165
What's in this PR
Commits (5):
This slice
Orchestrator event loop + on-demand spawner (dedupe, verb mapping, stateless restart)
Files affected:
orchestrator/event_loop.pyorchestrator/concurrent_executor.pyorchestrator/kubernetes_spawner.pyorchestrator/tests/test_event_loop.pyorchestrator/tests/test_kubernetes_spawner.pyorchestrator/tests/test_concurrent_executor.pyTasks (3) + acceptance criteria
orchestrator/event_loop.py(NEW) and hook it into theorchestrator/concurrent_executor.pycompletion-poll site (≈647-763), gated on EGG_EVENT_LOOP_OWNER=orchestrator: per role, consume the logic backing_derive_next_action(orchestrator/routes/consensus.py:296-422) IN-PROCESS; propose|ack|nack ⇒ request a one-shot spawn (TASK-2-2 entry, injectable for tests); confirm|complete ⇒ execute orchestrator-side with no pod (mirror the wrapper's agent-free handling); wait ⇒ nothing. Dedupe key = sha256(pipeline, slice, phase, role, action, event identity) where event identity is proposal_commit_sha for review verbs and target version + open NACK set for proposes; maintain an in-memory dedupe set reconciled against live Job labels; enforce at most one live pod per role+slice. On orchestrator restart, re-derive from the tracker (#2761) and reconcile against live Jobs — persist NO spawn bookkeeping. Poll interval env-tunable (default 5s); emit a structured spawn→invoke timing field per spawn. spawn_all()/pod-mode behavior unchanged (existing tests pass unmodified).orchestrator/kubernetes_spawner.py: spawn a per-event Job that sets EGG_EVENT_LOOP_OWNER=orchestrator plus the event identity (EGG_EVENT_ACTION, EGG_EVENT_DEDUPE_KEY, payload refs) in the Job env and carries the dedupe key as a Job label (the reconciliation handle for TASK-2-1); Job name derived from the existing egg-agent-<pipeline_id>-[<slice_id>-] convention (≈352-383) plus a short event discriminator, respecting the existing 63-char truncation handling; requesting a spawn for an already-live dedupe key adopts the existing Job rather than duplicating. The long-lived spawn_agent_job() path is unchanged for pod-mode callers. Worktree/session handling stays today's create-with-retry + per-spawn registration in this slice (slice 4 optimizes it).orchestrator/tests/test_event_loop.pywith a fake spawner — verb→decision mapping (all six verbs), sha256 dedupe across repeated polls AND across a simulated orchestrator restart (rebuild from consensus fixtures + fake live-Job labels, assert no duplicate spawn), at-most-one-live-pod invariant, agent-free confirm/complete, timing-field emission. Extendorchestrator/tests/test_kubernetes_spawner.py(one-shot entry: env + label contents, name budget, existing-key adoption; k8s API mocked per existing style) andorchestrator/tests/test_concurrent_executor.py(pod default ⇒ spawn_all unchanged, existing tests unmodified; orchestrator ⇒ no up-front pods, loop hooked at the poll site).Stack
issue-3064egg/issue-3064/slice-1