fix(orchestrator): commit pipeline state on every save and host-persist the state store (#3070) - #3084
Conversation
…st the state store (#3070) A redeploy on 2026-06-10 silently erased three in-flight Khan/webapp pipelines, including one parked at an approved refine HITL gate (get_status 404, absent from list_tasks). Root cause, orchestrator side: save_pipeline only committed prompt-driven pipelines (no issue_number) when force_commit=True, which fires solely on the auto-advance and completion paths — so a free-text pipeline parked at its first gate had never been committed to egg/pipeline-state at all. Its record existed only as an uncommitted file in the state worktree on an emptyDir volume; pod recreation rebuilt the worktree from the last committed branch tip and the pipeline vanished. The gate is a vestige of the removed local pipeline mode (#554 -> #1073). - save_pipeline/delete_pipeline: commit whenever commit=True, regardless of pipeline origin; drop the dead force_commit params and the two now-redundant call sites. - k8s local overlay: host-persist the orchestrator's egg-state volume (pipeline-worktree*) alongside repos, mirroring the gateway's #3005 session-store fix. The base no longer declares egg-state (it falls inside the home emptyDir, matching the gateway base) so the overlay add merges cleanly. - deployment validation: new pipeline-state-store-not-persistent rule (error) fires when an overlay persists repos but leaves egg-state ephemeral, mirroring session-store-not-persistent. The gateway-side half of #3070 (startup cleanup destroying parked pipelines' worktrees/branches) is a separate PR.
There was a problem hiding this comment.
Review
Fix correctly identifies the root cause of #3070 (gated commits hid prompt-driven pipeline state on a pod-lifetime volume) and addresses it at three layers: (1) the code-level gate removal in state_store.py, (2) the local-overlay hostPath for egg-state mirroring the gateway/#3005 fix, and (3) a new pipeline-state-store-not-persistent validation rule modelled on rule 6. The asymmetry the rule expresses — "egg-state at least as persistent as repos" — is the right invariant, and expressing it against emptyDir (rather than hostPath) keeps it forward-compatible with PVC/NFS/CSI futures.
I reviewed every changed file and traced the data flow from save_pipeline through the worktree-on-emptyDir/repos-on-hostPath asymmetry that produced #3070. I also walked the 70+ save_pipeline/delete_pipeline callers to confirm none still pass force_commit= and that the new "always commit on commit=True" semantics are what every existing caller actually wanted.
What I verified
force_commitis fully expunged:grep -rn "force_commit"returns no hits anywhere in the repo, and the two call sites inroutes/pipelines.py(auto-advance completion + next-phase transition) collapse to baresave_pipeline(pipeline)cleanly._commit_statestill serialises under_git_op()→bare_repo_lock(self.repo_path), so the volume-of-commits increase from prompt-driven saves does not introduce a concurrency hazard.- The strategic-merge add in
overlays/local/patches/orchestrator-volumes.yamlis now valid because the base no longer declaresegg-state(the comment correctly explains why declaring itemptyDirin base would produce a two-type merged volume). - Mount order at the merged Deployment is correct: base
homeemptyDir at/home/egg(mounts first) → overlayegg-statehostPath at/home/egg/.egg-state(more specific, shadows on top). The gateway base uses the same convention, so the asymmetry is intentional. - Shared hostPath with the gateway is safe: gateway uses
sessions/, orchestrator usespipeline-worktree-<repo>/— disjoint subpaths, no collision. - The
orchestratorexact-or-orchestrator-*-prefix scope matches the rule-6gatewayshape, and thetest_pipeline_state_store_rule_scoped_to_orchestratortest pins thatlitellm-orchestrator(or other unrelated names) does not match. - The absent-
egg-statemessage contains both "no ``egg-state`` volume is declared" and "not declared", so the disjunctive assertion intest_pipeline_state_store_rule_fires_when_egg_state_absentis robust.
Non-blocking suggestions
-
Test parity with rule 6. The session-store rule has explicit coverage for the PVC/PVC clean case, the PVC-worktrees / emptyDir-state failing case, and the
gateway-canarydash-variant. Rule 7's tests skip the equivalent three:test_pipeline_state_store_rule_clean_when_both_pvc_backed,test_pipeline_state_store_rule_fires_when_pvc_repos_emptydir_state, andtest_pipeline_state_store_rule_fires_on_orchestrator_dash_variant. The rule logic is the same expression as rule 6, but locking these in now matches the precedent and lets a future PR refactor both rules to a shared helper with confidence.orchestrator/tests/test_deployment_routes.py:907-960. -
Commit volume for prompt-driven pipelines is now substantially higher. Previously, only the two auto-advance/completion paths committed for prompt-driven pipelines. After this fix, every
save_pipelinecall commits — androutes/pipelines.pyhas 50+ such calls, several inside health-monitor and consensus-polling loops. Each commit is anadd+diff+commit+ best-effort async push underbare_repo_lock, so steady-state state-branch growth and per-save latency both rise for prompt-driven pipelines to whatever issue-numbered pipelines already incur (which we know is tolerable since they already do this). Worth a follow-up issue to measure once #3070 is closed, but not a reason to hold this PR. -
Cross-pod ownership when
HOST_UID != 1000. The orchestrator runs asrunAsUser: 1000in the base; the gateway entrypoint chowns/home/egg/.egg-statetoHOST_UID:HOST_GID. If a host deploys withHOST_UID != 1000, the orchestrator may hit EACCES writing the shared hostPath. The shared-volume design predates this PR and the local-dev convention hasHOST_UID=1000, so this is not a regression — but the comment inorchestrator-volumes.yamlclaims the disjoint-subpath sharing is safe without flagging the ownership coupling. Consider adding a note that the gateway's chown of the parent dir is what makes it work, and that cloud overlays moving to PVCs (the future intent the rule already accommodates) sidestep this entirely.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- Add three rule-6-parity tests for the new pipeline-state-store-not-persistent rule (PVC/PVC clean, PVC-repos/emptyDir-state firing, orchestrator-canary dash-variant firing). Mirrors the equivalent rule-6 trio so a future shared-helper refactor of both rules has full per-rule coverage. - Note the HOST_UID coupling in the orchestrator-volumes.yaml comment: the shared /home/egg/.egg-state hostPath works because the gateway entrypoint chowns the parent to HOST_UID:HOST_GID; HOST_UID != 1000 would EACCES the orchestrator (runAsUser: 1000), and cloud overlays on PVCs sidestep this entirely. Follow-up filed for the post-fix commit-volume measurement: #3090. Refs: #3084, #3070.
|
Thanks for the careful review. Per-item disposition:
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-Review
The delta since my previous review (861eb8f) is exactly the two items I flagged as non-blocking that the author committed to fixing in-PR (suggestions #1 and #3); suggestion #2 was correctly deferred to follow-up issue #3090 per my own explicit "not a reason to hold this PR" framing.
What I verified in the delta
-
Rule-7 test parity (commit
f7ea687): The three new tests pair 1:1 with their rule-6 counterparts both in shape and in what they pin:test_pipeline_state_store_rule_clean_when_both_pvc_backedmirrorstest_session_store_rule_clean_when_both_pvc_backed— PVC repos + PVC egg-state stays silent. Locks in the "emptyDir-checking, not hostPath-checking" generalisation so PVC/NFS/CSI futures don't need a code change.test_pipeline_state_store_rule_fires_when_pvc_repos_emptydir_statemirrorstest_session_store_rule_fires_when_pvc_worktrees_emptydir_state— same #3070 asymmetry, just with PVC in place of hostPath; fires withseverity=error.test_pipeline_state_store_rule_fires_on_orchestrator_dash_variantmirrorstest_session_store_rule_fires_on_gateway_dash_variant—orchestrator-canarymatches thename.startswith("orchestrator-")branch atroutes/deployment.py:947and the warning recordsDeployment/orchestrator-canary.- Ran
.venv/bin/pytest orchestrator/tests/test_deployment_routes.py -k pipeline_state_store -q— 8 passed in 0.66 s. - The fixture
_orchestrator_docalready accepted aname=kwarg (no new fixture surface needed), and the rule only gates onmetadata.name, not on theapplabel, so the dash-variant test'slabels: {app: orchestrator}doesn't taint the assertion.
-
HOST_UID coupling comment (commit
f7ea687,k8s/overlays/local/patches/orchestrator-volumes.yaml:71-78): The claim thatgateway/entrypoint.shchowns/home/egg/.egg-statetoHOST_UID:HOST_GIDis accurate —entrypoint.sh:245-249runschown -R "$HOST_UID:$HOST_GID"over/home/egg/.egg-state(and.egg-worktrees) beforegosu-dropping. With no accompanying chmod and the orchestrator pinned atrunAsUser: 1000, aHOST_UID != 1000deploy would EACCES the orchestrator on first write — the comment now flags this rather than just asserting the disjoint-subpath safety. The "cloud overlays moving to PVCs sidestep this entirely" framing also matches rule 7's emptyDir-checking design.
Verdict
No new concerns surfaced in the delta. All previously-raised non-blocking suggestions are either resolved in-PR or deferred to a tracked follow-up (#3090). Approve.
— Authored by egg
|
egg review completed. View run logs 3 previous review(s) hidden. |
…e branches in orphan sweeps (#3070) (#3087) * fix(gateway): gate worktree cleanup on pipeline liveness, never delete branches in orphan sweeps (#3070) Gateway-side half of #3070. On the 2026-06-10 redeploy, startup cleanup ran with active_containers=0 and force-removed every worktree under ~/.egg-worktrees — including pipeline-c978dac3, parked at an operator-approved refine HITL gate. Its contract was deleted with the worktree and branch_deleted=True left the approved analysis reachable only as a dangling commit. Root cause: the sweep equates "no live container" with "orphaned worktree". That is false for any pipeline parked at a HITL gate or between phases — no containers and no sessions is its NORMAL state, so the #3005/#3006 session-store persistence fix cannot protect it. This is the liveness-gate gap deliberately deferred in #3005. - New gateway/orchestrator_pipelines.py asks the orchestrator which pipelines are non-terminal (GET /api/v1/pipelines?active_only=true). Failure returns None, never an empty set, so callers can distinguish "verified nothing active" from "could not verify". - cleanup_orphaned_worktrees / list_orphan_worktree_dirs preserve worktrees anchored to an active pipeline ({pid} and {pid}-*, delimiter-bound). - startup_cleanup: the background thread polls the orchestrator (up to EGG_CLEANUP_ORCHESTRATOR_WAIT_SECONDS, default 600s — on a redeploy both pods restart together) and SKIPS the sweep entirely when liveness cannot be verified, logging at ERROR. Safe maintenance (git worktree prune, pack-file cleanup) still runs. - /api/v1/worktrees/prune returns 503 instead of sweeping blind when the orchestrator cannot answer. - Orphan sweeps never delete branches anymore: the sweep cannot know whether the work was pushed, and worktree-dir removal alone is recoverable while branch deletion strands the commits. Branch deletion stays with the explicit per-container teardown paths. Composes with the orchestrator-side fix (#3084): cleanup consults pipeline records, #3084 makes those records survive the restart. * review feedback: address non-blocking observations on #3070 cleanup - cleanup_stale_pipeline_worktrees: add active_pipeline_ids parameter and switch removals to delete_branch=False, mirroring cleanup_orphaned_worktrees. Currently tests-only per its docstring TODO; closes the same #3070 tripwire that would re-emerge the moment it's wired into a maintenance loop (an idle HITL-parked pipeline whose mtimes have aged past max_age_hours). - orchestrator_pipelines.py: drop the stdlib-logger ImportError fallback (egg_logging is a hard dep across gateway/*.py, and the fallback's stdlib Logger would TypeError on every kwarg call site the module makes). Import get_logger directly, matching gateway.py. - _is_pipeline_anchored: cross-reference list_worktrees_for_pipeline and #1865, explaining why the looser startswith(f'{pid}-') anchor is intentional vs the {pid}-[a-z_]+ regex (slice-scoped suffixes contain digits). - gateway/README.md: add a Local Development note pointing devs running outside a container at EGG_CLEANUP_ORCHESTRATOR_WAIT_SECONDS=0 to skip the default 600s startup wait against a non-existent orchestrator. - test_orchestrator_pipelines.py: factor the response-shape helper so test_returns_none_on_malformed_body exercises the same read()/decode() chain as the success path, varying only the bytes. --------- Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
Summary
Orchestrator-side half of #3070 (redeploy wiped in-flight pipeline state). A pod recreation erased every in-flight prompt-driven pipeline — including
pipeline-c978dac3, parked at an operator-approved refine HITL gate — because their records had never been committed to theegg/pipeline-statebranch.Root cause:
save_pipelinegated commits onissue_number is not Noneunlessforce_commit=True, andforce_commitfires only on the auto-advance and completion paths in_run_pipeline. Creation,awaiting_humanparking, decision recording, and theadvance_phase/HITL-revision/recover paths all saved without committing. The state worktree sits on an emptyDir, so those saves lived exactly as long as the pod. Evidence: the affected repo clone'segg/pipeline-statereflog has zero commits for any of the three lost pipelines; survivors were issue/Jira-keyed pipelines whose every save commits. The gate is a vestige of the removed local pipeline mode (#554 → #1073).Changes
state_store.py:save_pipeline/delete_pipelinecommit whenevercommit=True, regardless of pipeline origin. Deadforce_commitparams removed along with the two now-redundant call sites inroutes/pipelines.py.egg-state(thepipeline-worktree*store) becomes a hostPath alongsiderepos, mirroring the gateway's #3005 session-store fix — defense in depth for state saved between commits (e.g. tolerated commit failures). The base no longer declaresegg-state(it falls inside thehomeemptyDir, matching the gateway base convention); declaring it emptyDir in base would strategic-merge into an invalid two-type volume with the overlay's hostPath.pipeline-state-store-not-persistentrule (severity error) fires when an overlay persistsreposbut leavesegg-stateephemeral — same coupling-invariant shape as Rule 6 (session-store-not-persistent), self-gated so all-emptyDir base/cloud deploys stay silent.Testing
pytest orchestrator/tests/test_state_store.py orchestrator/tests/test_deployment_routes.py— 237 passed (includes new regression tests: prompt-driven save/delete commit by default; 5 tests for the new validation rule).kubectl kustomizeverified for both base (no dangling mount) and local overlay (clean hostPath volume).Related