Fix #2422: scope phase_exec.agents walks by (role, slice_id) - #2435
Conversation
`AgentExecution` now carries `slice_id`. Every consumer that walked `phase_exec.agents` looking for a role match is rescoped — without the tiebreaker, concurrent slice-2 + slice-3 same-role records in the same phase list were ambiguous. Producers populate `slice_id`: - `concurrent_executor._spawn_agent` (success + failure path) - `_concurrent_phase_run` agent_state record - `restart_agent` fall-through `AgentExecution` append Consumers fixed: - `restart_agent` mutation predicate (the headline bug from PR #2419 v1 review item #5: restarting slice-3 coder mutated slice-2's record) - `_update_agents_complete` — slice-2 BRC completion no longer flips slice-3's still-running agents to COMPLETE - `kubernetes_monitor._handle_consensus_stall_recovery` — defensive scope by `details["slice_id"]` so when the upstream `consensus_stall` check becomes slice-aware this path doesn't flip every slice's agents - `startup_reconciliation` — pipeline-level tracker reconstruction marks only pipeline-level agents COMPLETE - `handle_error_signal` — extract `slice_id` from payload, scope the "agent already COMPLETE" suppression by `(role, slice_id)` so a slice-2 coder finishing doesn't silently swallow slice-3's error Sandbox-side: `progress.progress_signal_error` now forwards `EGG_SLICE_ID` on error signal bodies, mirroring the BRC handler pattern in `brc._maybe_attach_slice_id`. Tests: - `test_restart_agent.TestRestartAgentSliceMatching` — the headline acceptance: slice-3 restart leaves slice-2 record untouched, plus a fall-through `AgentExecution.append` slice scoping check - `test_signals.TestAgentAlreadyCompleteSuppression` — slice-2 COMPLETE doesn't suppress slice-3 RUNNING error; positive case; malformed slice_id rejected with 400 - `sandbox/tests/test_progress_slice_routing.py` — error signal forwards `EGG_SLICE_ID`, request override wins, omitted when unset, malformed value rejected
There was a problem hiding this comment.
Verdict: Approve with non-blocking suggestions
The PR correctly implements the audit ask: every walker of phase_exec.agents that mutated by role alone is now scoped to (role, slice_id). I verified each of the four cited consumer paths, the producer-side population, the sandbox-side propagation, and the test coverage. The change is surgical, schema-compatible, and the new tests pass locally.
What I verified
| Concern | Result |
|---|---|
| All four cited consumers correctly scoped | ✅ restart_agent (pipelines.py:2572), _update_agents_complete (pipelines.py:12145), _handle_consensus_stall_recovery (kubernetes_monitor.py:775), startup_reconciliation (line 329), and handle_error_signal (signals.py:617) all match (role, slice_id) |
Every other for agent in *.agents site I found |
✅ pipelines.py:1935 (pipeline-termination, intentional all-slice fan-out), pipelines.py:2726 (restart_phase collects roles for full-phase respawn), pipelines.py:3480 (status enumeration, list output preserves duplicates), pipelines.py:12035/13138, kubernetes_monitor.py:450/1082, startup_reconciliation.py:239, health_checks/tier1/state_consistency.py, startup_state.py — all use container_id matching, which is unique across slices |
Producers populate slice_id |
✅ concurrent_executor._spawn_agent (success line 504, failure line 463), _run_concurrent_phase agent_state (pipelines.py:11917), restart_agent fall-through append (pipelines.py:2587) |
| Validation regex parity | ✅ slice_id_validation.SLICE_ID_PATTERN, brc._SLICE_ID_PATTERN, and progress._SLICE_ID_PATTERN all ^slice-[0-9]+$ |
| Migration safety | ✅ slice_id: str | None = Field(default=None, ...) — old persisted state files with no slice_id field load with None and still match pipeline-level scope |
| Tests | ✅ The two new TestRestartAgentSliceMatching cases and the three new TestAgentAlreadyCompleteSuppression cases pass. They cover headline acceptance (slice-3 restart leaves slice-2 untouched), fall-through-append-with-slice_id, slice-2 COMPLETE doesn't suppress slice-3 RUNNING error, slice-3 COMPLETE → slice-3 error suppressed, malformed slice_id → 400 |
Non-blocking suggestions
-
Recovery paths flip phase status unconditionally even though the agent walk is now slice-scoped (
kubernetes_monitor.py:811-812andstartup_reconciliation.py:334-335). The defensive agent-loop scoping is correct, butphase_exec.status = COMPLETEandphase_exec.completed_at = nowmutate the entire phase regardless ofstall_slice_id. The PR comment explicitly acknowledges this is fine today because consensus_stall is pipeline-level only — but it means the moment the upstream check becomes slice-aware, this path will mark the whole phase complete while other slices are still RUNNING. Consider scoping the phase-status flip to "no other slice is still active" or splittingphase_execinto per-slice status, as a follow-up. Same comment for thepods_to_stopsynthesis atkubernetes_monitor.py:794-802— that block already filters bycompleted_container_ids, which IS slice-scoped, so it's actually fine; only the phase-level mutation needs revisiting. -
_spawn_and_wait(pipelines.py:13069) still constructsAgentExecutionwith noslice_id. I traced callers and found none in production — the function appears to be referenced only from tests viamock_patch("routes.pipelines._spawn_and_wait", …). So this isn't a current bug, but if anyone resurrects the function for a sliced spawn, the record will silently land withslice_id=Noneand the new consumers' filters will miss it. Either delete the dead code or passslice_id=Noneexplicitly with a comment so a future maintainer plumbing through slice-aware spawning sees it. -
AgentExecution.slice_idhas no Pydantic validator. Every production write path uses validated values fromextract_slice_id/concurrent_executor._slice_id, but a hand-built fixture or migration tool can constructAgentExecution(slice_id="phase-2")and persist a non-canonical value. Defense-in-depth — adding a@field_validatormirroringSLICE_ID_PATTERN(allowNone, else require regex match) would close this. -
signals.py:586catchesExceptionfor_extract_slice_idbut the function only raisesValueError. Narrowing toValueErrorwould surface other bugs more clearly. Style nit. -
The new restart-agent test asserts on the in-memory pipeline object rather than what was passed to
store.save_pipeline. Becausemock_resolve.return_value = (mock_store, pipeline)shares one object, this works — but a future test that patches_resolve_pipelinedifferently could pass while the persisted state is wrong. Asserting onmock_store.save_pipeline.call_args[0][0]would be slightly more robust.
Style: I would not block on these
- The orchestrator-side comment "(consensus_stall check is currently pipeline-level only…)" buries an important caveat — that the recovery path will produce inconsistent state if the check ever populates
details["slice_id"]without a matching update to the phase-level mutations. Worth pulling into a TODO that references a follow-up issue. routes/pipelines.py:3480(status enumeration) returns duplicateroleentries for a multi-slice phase. Not changed by this PR and the per-entrycontainer_idlets a slice-aware consumer disambiguate, but downstream consumers that collapse by role would lose data. Out of scope; just flagging.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- Add @field_validator on AgentExecution.slice_id to reject non-canonical values at construction time. Defense-in-depth — production write paths already validate via extract_slice_id / concurrent_executor._slice_id, but a hand-built fixture or migration tool must not be able to smuggle a non-canonical value through AgentExecution(slice_id="phase-2", ...). - Narrow signals.py:586 Exception → ValueError, the only type _extract_slice_id raises. Other failure modes now surface clearly rather than being mis-reported as "Invalid slice_id". - Pass slice_id=None explicitly in _spawn_and_wait's AgentExecution construction with a comment pointing future maintainers at the (role, slice_id) walks added in #2422. The function has no production callers today; this is an explicit signal for future revivals. - Restart-agent slice tests now assert on the persisted dict from store.update_pipeline.call_args[0][1] instead of the in-memory pipeline. Guards against a future _resolve_pipeline refactor where the route forgets to save the mutation back. - TODO(#2441) comments at the two recovery paths (kubernetes_monitor._handle_consensus_stall_recovery, startup_reconciliation) flagging the unconditional phase-status mutations that need slice-aware scoping when consensus_stall becomes slice-aware. Issue #2441 tracks the deferred recovery-path fix.
Review feedback addressedThanks for the thorough audit. Per-item dispositions below. Non-blocking suggestions
Style nits
Verification
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Verdict: Approve
All five non-blocking suggestions from the prior review (commit fdc250c6) are correctly addressed in 6ba74653. I verified each disposition; the remaining issue is a single cosmetic comment artifact.
Verified addressed
| Prior suggestion | Disposition | Verified |
|---|---|---|
| 1. Recovery paths flip phase status unconditionally | Deferred to #2441 with TODO(#2441) comments at both sites |
✅ kubernetes_monitor.py:811-818, startup_reconciliation.py:334-340 |
2. _spawn_and_wait constructs AgentExecution with no slice_id |
Explicit slice_id=None + signposting comment |
✅ routes/pipelines.py:13068-13083 — comment correctly points future revivals at #2422's (role, slice_id) walks |
3. AgentExecution.slice_id has no Pydantic validator |
@field_validator on slice_id mirroring SLICE_ID_PATTERN |
✅ models.py:245-262 — None allowed, ^slice-[0-9]+$ enforced via fullmatch. New parametrised test covers phase-2, slice-, slice-2a, Slice-2, leading/trailing whitespace, empty string |
4. signals.py:586 catches Exception for _extract_slice_id |
Narrowed to ValueError |
✅ routes/signals.py:586 — now consistent with the eight other _extract_slice_id call sites in this file (lines 1049, 1244, 1334, 1406, 1537, 1803, 1911, 2011) |
| 5. Restart-agent test asserts on in-memory pipeline | Reads persisted dict from mock_store.update_pipeline.call_args[0][1] |
✅ tests/test_restart_agent.py:1042-1052 and 1126-1136 — matches the route's actual update_pipeline(pipeline_id, pipeline.model_dump(mode="json")) call at pipelines.py:2591 |
Spot-checks I did beyond verification
- Validator behaviour at runtime. Constructed
AgentExecution(role=CODER, slice_id="phase-2")against the actual code: rejected withValidationError → ValueError → "Invalid slice_id 'phase-2': must match 'slice-<N>'". Pydantic 2.13.4'sValidationErrorinherits fromValueError(confirmed via__mro__), sopytest.raises(ValueError, match="Invalid slice_id")in the new tests correctly catches it. - Validator runs on
model_validatetoo. Confirmed deserialisation viaAgentExecution.model_validate({"role": "coder", "slice_id": "phase-2"})raises — defense-in-depth covers the load-from-state-file path, not just direct construction. - No circular import risk.
slice_id_validation.pyimports onlyre+typing.Any;models.py's new top-levelfrom slice_id_validation import SLICE_ID_PATTERNis safe. - Backward compatibility of the new validator.
slice_idis itself a new field added in #2422, so there are no pre-existing persisted state files with non-canonical values that the validator could now reject. Old records load withslice_id=None(default) and pass. - Enum serialisation in test assertions. The test asserts
slice3_persisted["status"] == AgentExecutionStatus.RUNNING.value. Confirmedmodel_dump(mode="json")serialisesAgentExecutionStatus.RUNNINGto"running", which matches.value.
Non-blocking — one cosmetic nit
Backslash-escaped quotes in the new TODO comment at orchestrator/kubernetes_monitor.py:817:
# RUNNING. Scope to \"no other slice still active\" or splitcat -A confirms these are literal \" characters (backslash + double-quote), not display artifacts. Inside a Python # comment, \" is not an escape sequence — it renders verbatim, which is uglier than intended. The startup_reconciliation.py TODO doesn't have this issue. Trivial fix:
# RUNNING. Scope to "no other slice still active" or splitNot a blocker; flagging because the file got a cleanup pass for slice-aware semantics in this commit and the comment is the only thing left rough.
Style: optional, would not block
- The persisted-state assertion uses
call_args(last call only). Today the success path triggers exactly oneupdate_pipeline(the early FAILED/CANCELLED revival at line 2360 and the failure-path revert at line 2460 don't fire because the test pipeline starts RUNNING and spawn succeeds), so this is sound. Future-proofing would usecall_args_listand locate the call whose dict actually contains the agent mutation; current form is fine for the BAU path. extract_slice_idreturnsNonefor empty string at the request-payload boundary, while the field validator rejects empty string. Asymmetric, but only theoretically reachable via directAgentExecution(slice_id="")and the parametrised test pins the rejection. No production writer produces empty strings, so this is harmless.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Inside a Python # comment, \" is not an escape sequence — it renders verbatim. Strip the backslashes so the TODO reads cleanly. Cosmetic; no behaviour change. Addresses PR #2435 review nit on orchestrator/kubernetes_monitor.py:817.
Review feedback addressedThanks for the second-round verification. Per-item dispositions below. Non-blocking — one cosmetic nit
Style — optional, would not block
Verification
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Verdict: Approve
The only delta since 6ba74653 is the single cosmetic fix I flagged in the prior round.
Verified
orchestrator/kubernetes_monitor.py:817— literal\"characters in the TODO comment removed; line now reads# RUNNING. Scope to "no other slice still active" or split. Spot-checked the surrounding TODO block (lines 811-818) is otherwise identical, andgrep -P '\\"'acrosskubernetes_monitor.pyandstartup_reconciliation.pyreturns no remaining artifacts.- GitHub compare (
6ba74653...31092ad) confirmsorchestrator/kubernetes_monitor.pyis the only changed file, +1/-1. - No regression to addressed feedback — the validator on
AgentExecution.slice_id, theValueError-narrowedexceptinsignals.py, theupdate_pipeline.call_args[0][1]persisted-state assertions, the explicitslice_id=Noneat_spawn_and_wait, and theTODO(#2441)block atstartup_reconciliation.py:334-340all remain in place.
Disagreements stand
The two style nits I left as "would not block" in the prior round (call_args vs call_args_list, asymmetric empty-string handling between the request-boundary helper and the field validator) are also unchanged. The producer's reasoning — exactly one update_pipeline call on the BAU path, no production writer produces empty strings — holds.
Nothing else to flag.
— Authored by egg
|
egg review completed. View run logs 6 previous review(s) hidden. |
Fixes #2422. Follow-up to PR #2419 (v1 BRC review item #5).
Why
AgentExecutionhad no slice scope, so every consumer that walkedphase_exec.agentslooking foragent.role == Xmatched the first row— which on a multi-slice phase (concurrent slice-2 + slice-3 same-role
agents in the same list) may be the wrong slice. PR #2419 made the
operator restart route the first slice-aware caller in production,
but the audit asked for in this issue surfaced the same role-uniqueness
assumption in four other consumers.
What changed
Schema
AgentExecution.slice_id: str | None. Old persisted state files loadfine (defaults to
None).Producers populate
slice_idconcurrent_executor._spawn_agent(success + failure path) — pullsfrom
self._slice_id_concurrent_phase_run— the per-executionagent_staterecordcarries the route-level
slice_idrestart_agentfall-throughAgentExecutionappend carries theroute's
slice_idConsumers rescoped
routes/pipelines.py:2559(headline)(role, slice_id)instead of role aloneroutes/pipelines.py:_update_agents_completeslice_idso slice-2 BRC completion doesn't flip slice-3's still-running agents to COMPLETEkubernetes_monitor._handle_consensus_stall_recoverydetails["slice_id"]scope so the path is safe the momentconsensus_stallbecomes slice-awarestartup_reconciliationroutes/signals.handle_error_signalslice_idfrom payload (validated via_extract_slice_id); the "agent already COMPLETE, suppress" check now matches(role, slice_id)so slice-2 finishing doesn't silently swallow slice-3's errorSandbox-side propagation
progress.progress_signal_errornow forwardsEGG_SLICE_IDon theerror signal body, mirroring
brc._maybe_attach_slice_id. Withoutthis the orchestrator-side fix has no scope key to match on for
agent-emitted errors.
Consumers left untouched
Sites that already match by
container_id(unique across slices) areslice-safe —
routes/pipelines.py:1935,3469,11939,12023,kubernetes_monitor.py:450,1072,startup_reconciliation.py:239, andthe
health_checks/tier1walks. No change needed.Tests
test_restart_agent.TestRestartAgentSliceMatching— the headlineacceptance: slice-3 coder restart leaves slice-2 coder's
AgentExecutionuntouched; fall-through append carriesslice_idtest_signals.TestAgentAlreadyCompleteSuppression— slice-2COMPLETE doesn't suppress slice-3 RUNNING error; positive case
(slice-3 COMPLETE → slice-3 error suppressed); malformed
slice_id→ 400
sandbox/tests/test_progress_slice_routing.py(new) — errorsignal forwards
EGG_SLICE_ID, requestslice_idoverrides env,omitted when unset, malformed value rejected before the wire
Test plan
make linttest_restart_agent.py(50 incl.2 new),
test_signals.py(slice cases),test_progress_slice_routing.py(4 new),
test_brc_slice_routing.py,test_consensus_*,test_kubernetes_monitor.py,test_startup_reconciliation.py