fix(orchestrator): preserve slice/task runtime state across contract re-populate (#2908) - #2923
Conversation
…re-populate The safety-net populator (_populate_contract_from_plan) fires on every start_phase=implement restart, deliberately outside the contract_synced guard. It rebuilt contract.slices wholesale from the plan markdown via `contract.slices = contract_slices`, where every freshly parsed slice and task is PENDING with no runtime bookkeeping. That blind overwrite reset already-COMPLETE slices back to PENDING and dropped the parent_branch_at_creation / integration_base_sha stamped by a real slice run — so a sliced pipeline re-ran slice-1 on every restart and could never advance to slice-2 (#2908). Mirror the existing PRMetadata preservation: merge by slice id (and task id within a slice) so the plan supplies STRUCTURE while the existing contract supplies RUNTIME state. Unmatched ids (a re-plan adding/removing slices or tasks) keep the plan's fresh PENDING defaults. Adds _merge_preserved_slice_runtime() + a regression test.
There was a problem hiding this comment.
Summary
The fix correctly addresses the immediate #2908 strand-on-slice-1 bug — the merge-by-id pattern for slice/task runtime state mirrors the existing PRMetadata preservation a few lines down, and the slice-level field set is complete. However, the helper applies the same blind-overwrite anti-pattern to several other runtime-mutated Task fields that it does not enumerate, so the same class of silent runtime-state loss persists for notes, jira_action_status, and jira_key. These are not hypothetical concerns — they are explicitly documented as durable runtime state on the model itself.
Blocking
1. task.notes is not preserved across re-populate
_merge_preserved_slice_runtime (orchestrator/routes/pipelines.py:19666-19676) copies status, commit, checkpoint_id, review_cycles, escalated, delegation_attempts, and gaps from old → new tasks, but does not copy notes.
task.notes is a pure runtime field — the plan parser never sets it (shared/egg_contracts/plan_parser.py:276-288), so it always parses back as "". Multiple runtime writers exist:
- The Won't-Do drain failure path (
orchestrator/routes/pipelines.py:20430-20432) appends a structured failure note:f"wontdo drain failed: {reason}". - Agents writing through
mcp__task__update_notes/egg-contract update-notes, which is the documented surface for implementation narrative (sandbox/egg_agent_tools/handlers/task.py:287-295,sandbox/egg_lib/contract_cli.py:489-525). - The model docstring at
shared/egg_contracts/models.py:288-300explicitly anchors the applier's failure-recording contract onnotes: "or'failed'(with the reason recorded innotes) on failure."
Any start_phase=implement restart — exactly the scenario this PR exists to fix — will silently wipe every note the agents or orchestrator wrote during the prior run.
Fix: add new_task.notes = old_task.notes to the task-merge block.
2. task.jira_action_status is not preserved
Per the model docstring at shared/egg_contracts/models.py:288-300:
Durable apply-lifecycle status (#1557 risk_analyst R7). The APPLIER writes
'in_flight'to the contract before each gateway call and flips to'applied'on success or'failed'(with the reason recorded in notes) on failure. On re-run, the applier skips tasks already at'applied'and re-attempts{'pending', 'failed', None}.
The plan parser does set this field if jira_action_status: is present in the yaml-tasks (plan_parser.py:240, 287), but the planner emits an initial state — the runtime advances it. After:
pipelines.py:20428: Won't-Do drain stampstarget_task.jira_action_status = "applied" if ok else "failed".sandbox/egg_agent_tools/handlers/task.py:310-319: the notes-prefix projector writes the runtime-transitioned status.
…a re-populate reads whatever was in the plan (commonly "pending" or None) and silently overwrites the runtime "applied". The applier's idempotency invariant ("skip tasks already at applied") is then broken: an already-created Jira issue will be re-created on next apply, producing duplicate tickets.
Same exact failure shape as the #2908 strand-on-slice-1 bug — runtime state silently reset to plan defaults on re-entry. The fact that the safety-net call site this PR cites (pipelines.py:21516) is start_phase=implement-gated does not contain the blast radius: _merge_preserved_slice_runtime lives inside _populate_contract_from_plan, which is also called from the inline-plan path (pipelines.py:21330) and the HITL plan-gate path (pipelines.py:23930). Any path that triggers a re-populate after the apply phase or Won't-Do drain has stamped a status will wipe it.
Fix: add new_task.jira_action_status = old_task.jira_action_status to the task-merge block. The planner-author intent argument is the same as for status and commit — the runtime value always reflects the more-advanced state.
3. task.jira_key is not preserved
Per shared/egg_contracts/models.py:261-273:
Populated by the task-planner for
edit/wontdo/split-of/consolidate-intoactions against pre-existing children, and by the APPLIER after a successfulcreateaction — the applier writes the freshly-allocated key back to the contract so idempotent re-runs skip the create.
The create-action pattern is exactly the case the merge breaks: the planner emits jira_key: null on a fresh create, the applier writes ENG-1234 back after gateway-creating the issue, and the next re-populate reads null from the plan and silently overwrites the applier's runtime value. The applier loses its idempotency anchor and will re-create the same Jira issue on the next run.
This one is nuanced — a re-plan that intentionally changes jira_key should win — but the same nuance applies to task.status (a re-plan that re-introduces a completed task as a new task wouldn't want the old COMPLETE), and the chosen design is to favor the old value over the plan value. Apply the same rule here, or document explicitly why jira_key is different.
Fix: add new_task.jira_key = old_task.jira_key to the task-merge block (or, if asymmetric semantics are intentional, copy only when old_task.jira_key is not None).
Non-blocking
4. Test coverage is narrow relative to the contract
test_populate_contract_from_plan_preserves_slice_and_task_runtime only asserts on five preserved fields:
- Slice:
status,parent_branch_at_creation,integration_base_sha - Task:
status,commit
The helper claims to preserve fifteen+ fields across the two models. The other ten (slice.commit, slice.review_cycles, slice.review_feedback, slice.escalated, slice.escalation_reason; task.checkpoint_id, task.review_cycles, task.escalated, task.delegation_attempts, task.gaps) are untested — a future field-list typo would silently regress them. Add one assertion per preserved field, ideally driven from a single fixture that stamps each one to a distinctive value. The "untouched task keeps PENDING" assertion handles the unmatched-task case correctly, but the unmatched-slice case (a re-plan that drops a slice, or adds a new one) is also untested — exercise it.
5. List fields are aliased, not copied
new_slice.review_feedback = old_slice.review_feedback and new_task.gaps = old_task.gaps (and the related tasks cross-ref) assign the lists by reference. Today this is harmless because the old contract instance is discarded immediately, but the helper now silently couples new/old mutations through these aliases. Defensive list(...) (and shallow-copy of the contained models if those carry mutable substate) would future-proof.
6. Any typing erases the documented contract
The helper signature is new_slices: list[Any], old_slices: list[Any]. The function is heavily field-name-dependent — a Slice rename or field removal won't surface here at type-check time. Use list[Slice] from egg_contracts.models (kept under a TYPE_CHECKING guard if the runtime import order matters). This is consistent with the Slice/Task typing already in use elsewhere in this module.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…key, type the helper, defensive list copies, expand tests #2923 review (egg-reviewer[bot]): Blocking — three more runtime-mutated Task fields the merge helper was not preserving: - task.notes: APPLIER writes Won't-Do drain failure reasons here and agents write narrative via mcp__task__update_notes; the plan parser always emits "". Without preservation, every start_phase=implement restart wiped accumulated notes. - task.jira_action_status: APPLIER advances pending -> in_flight -> applied/failed (#1557 R7); idempotency depends on 'applied' surviving re-populate so the next apply skips it instead of re-creating the Jira issue. - task.jira_key: APPLIER writes the freshly-allocated key back after a 'create' action so re-runs skip the create; plan parser emits None on 'create' actions, so re-populate would otherwise strand the applier into creating duplicate tickets. Non-blocking: - Helper now types its args as list[ContractSlice] (under TYPE_CHECKING via the existing pattern in this file) instead of list[Any], so a Slice rename / field removal surfaces here at type-check time. - review_feedback / gaps are now defensively list-copied so the merge helper does not alias-leak old <-> new contract mutations. - Test coverage expanded to one assert per preserved slice field (8) and per preserved task field (10 — including the new notes / jira_action_status / jira_key), plus a new test_populate_contract_ from_plan_handles_unmatched_slices that exercises both the unmatched-old-id (re-plan drops a slice) and unmatched-new-id (re-plan adds a slice) branches the helper's docstring promises.
|
Thanks for the thorough review — addressed all six items in commit Per-item disposition1.
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Summary
Items 1-6 from the prior review are correctly addressed: task.notes / task.jira_action_status / task.jira_key are now preserved, the docstring anchors each new field on its runtime writer, the helper signature is list[ContractSlice] (with the same # noqa: UP037 pattern used at pipelines.py:16908), review_feedback / gaps use defensive list(...) copies, and the test expansion stamps every preserved field independently plus exercises the unmatched-slice branches.
The new commit does not fix a closely-related runtime field that falls under the same scope as items 1-3: task.role. The pattern that made the prior items blocking applies here verbatim.
Blocking
1. task.role is not preserved across re-populate
_merge_preserved_slice_runtime (orchestrator/routes/pipelines.py:19692-19704) now copies ten Task fields including delegation_attempts (the SYSTEM-owned impasse-delegation counter), but does not copy task.role — the SYSTEM-owned field that delegation_attempts is designed to track.
The pair is explicit in three places:
shared/egg_contracts/roles.py:49-59enumerates the two as the only SYSTEM-owned task fields and ties them together in the same docstring:delegation_attemptsis "bumped by the orchestrator alongside any role-flip so a second impasse on the same task escalates to HITL instead of looping forever."shared/egg_contracts/models.py:240-247(thedelegation_attemptsfield docstring) anchors the invariant directly on the role mutation: "Bumped by the orchestrator when a producer emits anImpasseand the orchestrator mutatesroleto the suggested alternative."orchestrator/impasse_routing.py:325-346documents the routing-policy contract: every DELEGATE decision "flipstask.role+ bumpstask.delegation_attempts" — both go throughapply_mutationunderRole.SYSTEMbecause "only SYSTEM ownsphases.*.tasks.*.roleandphases.*.tasks.*.delegation_attempts."
The runtime mutation itself is at orchestrator/impasse_routing.py:437-451 (role_path = f"phases.{slice_idx}.tasks.{task_idx}.role" → apply_mutation(..., field_path=role_path, new_value=impasse.suggested_role, ...)), and the plan parser emits the planner-author's original role from yaml-tasks (shared/egg_contracts/plan_parser.py:899, 970) — so re-populate downgrades the runtime value to the plan default, identical to the failure shape for jira_action_status.
The slice-loop consumer at orchestrator/routes/pipelines.py:6045-6052 filters tasks by task.role, so the post-merge inconsistency is observable: after a re-populate following a successful delegation,
delegation_attempts == 1(preserved by this PR)rolereverts to the planner-author default ("coder")- The slice loop re-spawns coder per the reset role, undoing the delegation's effect
- A second coder impasse on the same task now hits
DELEGATION_LIMIT == 1and escalates to HITL (orchestrator/impasse_routing.py:78) — even though, from the contract's surface, no delegation has visibly happened
This is the same start_phase=implement restart path #2908 set out to fix. The fix is one line, symmetric to delegation_attempts:
new_task.role = old_task.roleThe expanded test at orchestrator/tests/test_short_flow_contract_population.py:286-306 should also stamp t.role = "tester" (a delegation flip from the plan default "coder") and assert t_after.role == "tester". Without that, a future field-list typo regression here is silent — the very failure mode the test-coverage expansion was designed to prevent.
Non-blocking
2. Docstring should enumerate the role + delegation_attempts pairing
The docstring at orchestrator/routes/pipelines.py:19649-19666 already calls out the per-field invariants for notes / jira_action_status / jira_key. Adding a parallel entry for role (citing impasse_routing.py and the field-ownership comment) makes the pairing with delegation_attempts explicit so the next addition doesn't reintroduce the same gap.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
The prior commit preserved task.delegation_attempts (SYSTEM-owned impasse-delegation counter) but not task.role (the SYSTEM-owned field that the counter is designed to track). impasse_routing.py flips role and bumps delegation_attempts together under Role.SYSTEM; preserving the counter without the role means a restart re-spawns the original producer per the reset role, undoes the delegation's effect, and the next impasse trips DELEGATION_LIMIT and escalates to HITL — even though from the contract's surface no delegation visibly happened. Add new_task.role = old_task.role to the task-merge block alongside delegation_attempts, expand the docstring to call out the paired SYSTEM-owned invariant explicitly, and extend the regression test to stamp t.role = 'tester' (flipping from the plan default None) and assert it survives re-populate.
|
Thanks for catching the paired Per-item disposition1.
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Summary
The single blocking item from the prior review (task.role not preserved alongside delegation_attempts) is correctly addressed. _merge_preserved_slice_runtime at orchestrator/routes/pipelines.py:19710 now copies new_task.role = old_task.role sequenced immediately before the delegation_attempts copy, with an inline comment anchoring the pairing on the impasse-routing invariant. The non-blocking docstring suggestion (item 2 from prior review) is also addressed — orchestrator/routes/pipelines.py:19655-19665 enumerates the SYSTEM-owned pair as a single bullet that cites impasse_routing.py, the paired apply_mutation cycle, and the concrete failure mode (counter without role → re-spawn original producer → trip DELEGATION_LIMIT → escalate to HITL).
I verified the fix is complete by:
- Re-inventorying the role-ownership table at
shared/egg_contracts/roles.py:30-60—phases.*.tasks.*.roleandphases.*.tasks.*.delegation_attemptsare bothRole.SYSTEM, and both are now preserved. - Confirming the paired
apply_mutationcycle atorchestrator/impasse_routing.py:437-465— role and counter are mutated in the sameRoutingDecision, never independently. - Confirming the slice-loop consumer at
orchestrator/routes/pipelines.py:6045-6052filters bytask.role(so post-merge inconsistency would have observable consequences). - Re-checking the remaining Task/Slice fields against runtime mutators —
files_affectedisIMPLEMENTER-owned in the table but has no production-code writer (only test fixtures), and contract-levelacceptance_criteria.*.verifiedis preserved naturally because the populator never re-assignscontract.acceptance_criteria. No other unguarded runtime fields remain.
The regression test at orchestrator/tests/test_short_flow_contract_population.py:298 stamps t.role = "tester" and asserts t_after.role == "tester", with a comment that ties it back to the delegation_attempts line so a future field-list typo regresses one or both rather than silently undoing the delegation.
Non-blocking
1. Minor doc inaccuracy in the new test comment
At orchestrator/tests/test_short_flow_contract_population.py:293-297, the comment says:
The plan default for task-1-1 is
"coder"; a successful delegation would flip it to"tester"and bump the counter — both must survive a re-populate together…
The SAMPLE_PLAN (lines 19-54) does not specify a role: field for task-1-1, so the plan parser emits role=None (the model default at shared/egg_contracts/models.py:226-229). The "coder" interpretation comes from the slice-loop dispatcher at pipelines.py:6051 — it treats role=None as coder for filtering purposes, but the durable value the populator round-trips is None, not "coder". The commit message for d14c614 gets this right ("flipping from the plan default of None — SAMPLE_PLAN doesn't specify a role"); the inline comment is the only place that says "coder".
The test assertion is still meaningful regardless — "tester" is a clearly different value from both None and "coder", so preservation is exercised either way. Worth a one-word fix on a future touch (None → consumed-as-coder-by-the-dispatcher) but not blocking.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
The prior comment said the plan default for task-1-1 is `"coder"`, but `SAMPLE_PLAN` doesn't specify a `role:` field, so the plan parser emits `role=None` (the model default). `"coder"` is what the slice-loop dispatcher at `pipelines.py:6045-6052` consumes `None` as for filtering — not the durable value the populator round-trips. Updates the inline comment to match the commit message for d14c614, which already had this right.
|
Thanks for the close re-review — the only remaining item was the non-blocking doc inaccuracy in the test comment, addressed in commit Per-item disposition1. Minor doc inaccuracy in the new test comment —
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Summary
The single non-blocking item from my prior review (the test comment at orchestrator/tests/test_short_flow_contract_population.py:293-300 that incorrectly described the plan default for task-1-1 as "coder") is correctly addressed in commit 11001d5. The updated comment now:
- Correctly states
SAMPLE_PLANdoes not specify arole:for task-1-1, so the plan parser emitsrole=None. - Pins the
"coder"interpretation to where it actually lives — the slice-loop dispatcher atorchestrator/routes/pipelines.py:6045-6052, which consumestask.role is Noneas coder for filtering (verified: the dispatcher's filter predicate istask.role == role_value or (task.role is None and role_value == "coder")). - Retains the test's pedagogical point —
"tester"is still a clearly different value from bothNoneand"coder", so the preservation assertion remains meaningful.
No new code changed since d14c614; the diff is comment-only. All previously-resolved blocking items (slice/task runtime preservation including notes, jira_action_status, jira_key, role + delegation_attempts pairing) remain correctly addressed at orchestrator/routes/pipelines.py:19678-19718, and the docstring at orchestrator/routes/pipelines.py:19649-19676 still enumerates each preserved field with its runtime-writer invariant.
No further issues found.
— Authored by egg
|
egg review completed. View run logs 9 previous review(s) hidden. |
…k branch (#2928) (#2930) * fix(orchestrator): base non-root slices on dependency parent, not work branch (#2928) `_resolve_slice_base_branch` mis-based fresh non-root slices onto the pipeline `work` branch instead of their dependency parent's integration branch, silently breaking the stacked-PR invariant. The slice-4 TASK-4-3 arm computed `merge_base(slice_integration_branch, derived_parent)` and routed a `None` result to `pipeline_branch`. But a slice's integration branch is created *after* the resolver runs, so on a slice's first run it has no fork point — `merge_base` returns `None` for a fresh slice exactly as it does for a genuinely orphaned one. The two were conflated, mis-basing every fresh non-root slice onto `work`. This was harmless only while `work` sat at the parent slice's tip; once `work` advanced ahead (e.g. a stray contract-state commit, #2923) the slice lost its parent's commits and tripped `restricted_path_modified` downstream (#2927), wedging the slice and restart-looping the producer. Replace the merge-base check with a parent-branch-existence probe: - parent branch exists on origin → stack on the dependency-derived parent (correct for fresh AND legacy slices) - parent branch absent → it was merged into `work` and cascade-deleted, so `work` already holds its commits → `pipeline_branch` fallback - probe raises → conservatively assume the parent exists; never silently swap a real slice onto `work` The gateway `merge_base` method is retained as a general utility (no longer wired into the resolver). Tests updated to the new parent-existence semantics, including a regression for the fresh-slice case the old probe mis-routed. * fix: probe must raise on gateway error, not swallow it (PR #2930 review) The PR's _probe_parent_branch_exists wrapper called get_remote_branch_sha, which swallows all exceptions and returns None for BOTH 'branch absent' AND 'gateway error'. That collapsed the two outcomes the resolver tried to distinguish, so a flaky gateway routed a real slice onto pipeline_branch — re-creating the exact #2928 wedge the PR claims to fix. The resolver's try/except for the conservative-default path was dead code in production. Add GatewayClient.ls_remote_branch_strict — the strict tri-state variant of ls_remote_branch that propagates gateway / network / policy failures instead of collapsing them to False. Wire _probe_parent_branch_exists to the strict method so the resolver's try/except fires when the probe genuinely cannot be performed. Also tighten the resolver docstring with a note that parent_branch_exists and extant_branches are mutually exclusive in practice (the production caller passes only the former; the stacked-PR reconciler passes only the latter). * refactor: dedupe ls_remote helpers + close envelope gap (PR #2930 review) Address the four non-blocking suggestions on the approving re-review: * Code duplication — extract `_ls_remote_branch_impl` as the shared worker. `ls_remote_branch` wraps it with a broad except → False; `ls_remote_branch_strict` calls it directly. The lenient-vs-strict contract now lives in a 2-line outer policy rather than ~60 lines of near-identical bodies, so future divergence (e.g. retry behaviour) can't accidentally land in only one path. * Drop `(result or {}).get(...)` — the strict variant's defensive `or {}` was incoherent with its propagate-any-failure contract: if `_make_request` *did* return None, the variant would silently produce False (the exact silent-fail mode the strict path exists to prevent). The shared impl now uses `result.get(...)` like the rest of the client. * Close the `{"success": false, ...}` envelope hole — the strict contract is "propagate any gateway failure," but a 200 OK with envelope-level success=false was a (narrow) hole. Added the same envelope check `register_session` already does; envelope failures now raise GatewayError instead of collapsing to False. * Pin success-path session teardown — added `mock_del.assert_called_once_with(...)` to `test_present_branch_returns_true`. The error-path teardown was already pinned; pinning the success path closes the symmetric leak vector (a future refactor that drops the `finally` block would silently leak gateway sessions on every successful probe). New test `test_envelope_success_false_raises` exercises the envelope- level failure path against the production code surface. TestLsRemoteBranchStrict: 6 passed (was 5). Existing slice-4 + state-store regression tests still green; the public method signatures are unchanged, so call sites and mock-based tests (`mock_client.ls_remote_branch.return_value = …`) are unaffected. --------- Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
…k branch (#2928) (#2930) * fix(orchestrator): base non-root slices on dependency parent, not work branch (#2928) `_resolve_slice_base_branch` mis-based fresh non-root slices onto the pipeline `work` branch instead of their dependency parent's integration branch, silently breaking the stacked-PR invariant. The slice-4 TASK-4-3 arm computed `merge_base(slice_integration_branch, derived_parent)` and routed a `None` result to `pipeline_branch`. But a slice's integration branch is created *after* the resolver runs, so on a slice's first run it has no fork point — `merge_base` returns `None` for a fresh slice exactly as it does for a genuinely orphaned one. The two were conflated, mis-basing every fresh non-root slice onto `work`. This was harmless only while `work` sat at the parent slice's tip; once `work` advanced ahead (e.g. a stray contract-state commit, #2923) the slice lost its parent's commits and tripped `restricted_path_modified` downstream (#2927), wedging the slice and restart-looping the producer. Replace the merge-base check with a parent-branch-existence probe: - parent branch exists on origin → stack on the dependency-derived parent (correct for fresh AND legacy slices) - parent branch absent → it was merged into `work` and cascade-deleted, so `work` already holds its commits → `pipeline_branch` fallback - probe raises → conservatively assume the parent exists; never silently swap a real slice onto `work` The gateway `merge_base` method is retained as a general utility (no longer wired into the resolver). Tests updated to the new parent-existence semantics, including a regression for the fresh-slice case the old probe mis-routed. * fix: probe must raise on gateway error, not swallow it (PR #2930 review) The PR's _probe_parent_branch_exists wrapper called get_remote_branch_sha, which swallows all exceptions and returns None for BOTH 'branch absent' AND 'gateway error'. That collapsed the two outcomes the resolver tried to distinguish, so a flaky gateway routed a real slice onto pipeline_branch — re-creating the exact #2928 wedge the PR claims to fix. The resolver's try/except for the conservative-default path was dead code in production. Add GatewayClient.ls_remote_branch_strict — the strict tri-state variant of ls_remote_branch that propagates gateway / network / policy failures instead of collapsing them to False. Wire _probe_parent_branch_exists to the strict method so the resolver's try/except fires when the probe genuinely cannot be performed. Also tighten the resolver docstring with a note that parent_branch_exists and extant_branches are mutually exclusive in practice (the production caller passes only the former; the stacked-PR reconciler passes only the latter). * refactor: dedupe ls_remote helpers + close envelope gap (PR #2930 review) Address the four non-blocking suggestions on the approving re-review: * Code duplication — extract `_ls_remote_branch_impl` as the shared worker. `ls_remote_branch` wraps it with a broad except → False; `ls_remote_branch_strict` calls it directly. The lenient-vs-strict contract now lives in a 2-line outer policy rather than ~60 lines of near-identical bodies, so future divergence (e.g. retry behaviour) can't accidentally land in only one path. * Drop `(result or {}).get(...)` — the strict variant's defensive `or {}` was incoherent with its propagate-any-failure contract: if `_make_request` *did* return None, the variant would silently produce False (the exact silent-fail mode the strict path exists to prevent). The shared impl now uses `result.get(...)` like the rest of the client. * Close the `{"success": false, ...}` envelope hole — the strict contract is "propagate any gateway failure," but a 200 OK with envelope-level success=false was a (narrow) hole. Added the same envelope check `register_session` already does; envelope failures now raise GatewayError instead of collapsing to False. * Pin success-path session teardown — added `mock_del.assert_called_once_with(...)` to `test_present_branch_returns_true`. The error-path teardown was already pinned; pinning the success path closes the symmetric leak vector (a future refactor that drops the `finally` block would silently leak gateway sessions on every successful probe). New test `test_envelope_success_false_raises` exercises the envelope- level failure path against the production code surface. TestLsRemoteBranchStrict: 6 passed (was 5). Existing slice-4 + state-store regression tests still green; the public method signatures are unchanged, so call sites and mock-based tests (`mock_client.ls_remote_branch.return_value = …`) are unaffected. --------- Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
…re-populate (#2908) (#2923) * fix(orchestrator): preserve slice/task runtime state across contract re-populate The safety-net populator (_populate_contract_from_plan) fires on every start_phase=implement restart, deliberately outside the contract_synced guard. It rebuilt contract.slices wholesale from the plan markdown via `contract.slices = contract_slices`, where every freshly parsed slice and task is PENDING with no runtime bookkeeping. That blind overwrite reset already-COMPLETE slices back to PENDING and dropped the parent_branch_at_creation / integration_base_sha stamped by a real slice run — so a sliced pipeline re-ran slice-1 on every restart and could never advance to slice-2 (#2908). Mirror the existing PRMetadata preservation: merge by slice id (and task id within a slice) so the plan supplies STRUCTURE while the existing contract supplies RUNTIME state. Unmatched ids (a re-plan adding/removing slices or tasks) keep the plan's fresh PENDING defaults. Adds _merge_preserved_slice_runtime() + a regression test. * address review feedback: preserve task.notes/jira_action_status/jira_key, type the helper, defensive list copies, expand tests #2923 review (egg-reviewer[bot]): Blocking — three more runtime-mutated Task fields the merge helper was not preserving: - task.notes: APPLIER writes Won't-Do drain failure reasons here and agents write narrative via mcp__task__update_notes; the plan parser always emits "". Without preservation, every start_phase=implement restart wiped accumulated notes. - task.jira_action_status: APPLIER advances pending -> in_flight -> applied/failed (#1557 R7); idempotency depends on 'applied' surviving re-populate so the next apply skips it instead of re-creating the Jira issue. - task.jira_key: APPLIER writes the freshly-allocated key back after a 'create' action so re-runs skip the create; plan parser emits None on 'create' actions, so re-populate would otherwise strand the applier into creating duplicate tickets. Non-blocking: - Helper now types its args as list[ContractSlice] (under TYPE_CHECKING via the existing pattern in this file) instead of list[Any], so a Slice rename / field removal surfaces here at type-check time. - review_feedback / gaps are now defensively list-copied so the merge helper does not alias-leak old <-> new contract mutations. - Test coverage expanded to one assert per preserved slice field (8) and per preserved task field (10 — including the new notes / jira_action_status / jira_key), plus a new test_populate_contract_ from_plan_handles_unmatched_slices that exercises both the unmatched-old-id (re-plan drops a slice) and unmatched-new-id (re-plan adds a slice) branches the helper's docstring promises. * address review: preserve task.role across re-populate (#2908) The prior commit preserved task.delegation_attempts (SYSTEM-owned impasse-delegation counter) but not task.role (the SYSTEM-owned field that the counter is designed to track). impasse_routing.py flips role and bumps delegation_attempts together under Role.SYSTEM; preserving the counter without the role means a restart re-spawns the original producer per the reset role, undoes the delegation's effect, and the next impasse trips DELEGATION_LIMIT and escalates to HITL — even though from the contract's surface no delegation visibly happened. Add new_task.role = old_task.role to the task-merge block alongside delegation_attempts, expand the docstring to call out the paired SYSTEM-owned invariant explicitly, and extend the regression test to stamp t.role = 'tester' (flipping from the plan default None) and assert it survives re-populate. * address review: clarify test comment on role plan default The prior comment said the plan default for task-1-1 is `"coder"`, but `SAMPLE_PLAN` doesn't specify a `role:` field, so the plan parser emits `role=None` (the model default). `"coder"` is what the slice-loop dispatcher at `pipelines.py:6045-6052` consumes `None` as for filtering — not the durable value the populator round-trips. Updates the inline comment to match the commit message for d14c614, which already had this right. --------- Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
Problem
A sliced implement-phase pipeline (
issue-2908-impl2) was stuck: every restart re-ran slice-1 instead of advancing to slice-2, even though slice-1 had already run, produced 20 commits, and had an open PR (#2918). The persisted contract showed all six slicespending, 0 taskscomplete— on the work branch, the slice-1 branch, and the slice-2 branch.Root cause
_populate_contract_from_planrebuilds the contract's slices wholesale from the plan markdown:parse_plan(...).to_contract_slices()always returns freshPENDINGslices/tasks with the runtime bookkeeping fields unset. This is a blind replace, not a merge — it already preservesPRMetadataruntime fields (context_pr_number,deferred_actions) a few lines down, but does nothing equivalent for slices.The destructive caller is the safety-net populator, which fires on every
start_phase=implementrestart, deliberately outside theif not contract_synced:guard (so it runs on restarts). Result: every restart re-parses the plan as all-PENDING, wiping slice-1'sCOMPLETEstatus and theparent_branch_at_creation/integration_base_shaa real run stamped — before the slice loop reads the contract. The pipeline can therefore never advance past slice-1.Confirmed by git forensics: slice-1 was never recorded
completein branch history, and the committed contract hasparent_branch_at_creation=null/integration_base_sha=nullon slice-1 — i.e. it's a fresh plan re-parse, not the contract slice-1 ran against.Fix
Add
_merge_preserved_slice_runtime()and call it before the assignment. It merges by slice id (and task id within a slice): the plan supplies STRUCTURE (names, descriptions, dependencies, acceptance criteria) while the existing contract supplies RUNTIME state (status,parent_branch_at_creation,integration_base_sha,commit, review/escalation fields; per-taskstatus,commit,checkpoint_id, review/escalation/gaps). Unmatched ids — a re-plan that adds or removes slices/tasks — simply keep the plan's freshPENDINGdefaults. This mirrors the existingPRMetadatapreservation pattern.Tests
test_populate_contract_from_plan_preserves_slice_and_task_runtime: mark a slice + a taskCOMPLETE, stamp the branch/sha bookkeeping, re-run the populator, and assert runtime survives while structure refreshes.test_short_flow_contract_population.py(28) green, plus 167 populator-adjacent tests across 6 suites.Operational note
The live
issue-2908-impl2pipeline needs a manual nudge to recover (durably set slice-1completeonce this is deployed); tracked separately. This PR fixes the underlying defect so it can't recur.