Skip to content

fix(orchestrator): commit pipeline state on every save and host-persist the state store (#3070) - #3084

Merged
jwbron merged 2 commits into
mainfrom
egg/3070-durable-pipeline-state
Jun 11, 2026
Merged

fix(orchestrator): commit pipeline state on every save and host-persist the state store (#3070)#3084
jwbron merged 2 commits into
mainfrom
egg/3070-durable-pipeline-state

Conversation

@jwbron

@jwbron jwbron commented Jun 10, 2026

Copy link
Copy Markdown
Owner

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 the egg/pipeline-state branch.

Root cause: save_pipeline gated commits on issue_number is not None unless force_commit=True, and force_commit fires only on the auto-advance and completion paths in _run_pipeline. Creation, awaiting_human parking, decision recording, and the advance_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's egg/pipeline-state reflog 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_pipeline commit whenever commit=True, regardless of pipeline origin. Dead force_commit params removed along with the two now-redundant call sites in routes/pipelines.py.
  • k8s local overlay: orchestrator egg-state (the pipeline-worktree* store) becomes a hostPath alongside repos, 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 declares egg-state (it falls inside the home emptyDir, matching the gateway base convention); declaring it emptyDir in base would strategic-merge into an invalid two-type volume with the overlay's hostPath.
  • deployment validation: new pipeline-state-store-not-persistent rule (severity error) fires when an overlay persists repos but leaves egg-state ephemeral — 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 kustomize verified for both base (no dangling mount) and local overlay (clean hostPath volume).

Related

…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.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_commit is fully expunged: grep -rn "force_commit" returns no hits anywhere in the repo, and the two call sites in routes/pipelines.py (auto-advance completion + next-phase transition) collapse to bare save_pipeline(pipeline) cleanly.
  • _commit_state still 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.yaml is now valid because the base no longer declares egg-state (the comment correctly explains why declaring it emptyDir in base would produce a two-type merged volume).
  • Mount order at the merged Deployment is correct: base home emptyDir at /home/egg (mounts first) → overlay egg-state hostPath 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 uses pipeline-worktree-<repo>/ — disjoint subpaths, no collision.
  • The orchestrator exact-or-orchestrator-*-prefix scope matches the rule-6 gateway shape, and the test_pipeline_state_store_rule_scoped_to_orchestrator test pins that litellm-orchestrator (or other unrelated names) does not match.
  • The absent-egg-state message contains both "no ``egg-state`` volume is declared" and "not declared", so the disjunctive assertion in test_pipeline_state_store_rule_fires_when_egg_state_absent is robust.

Non-blocking suggestions

  1. 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-canary dash-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, and test_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.

  2. 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_pipeline call commits — and routes/pipelines.py has 50+ such calls, several inside health-monitor and consensus-polling loops. Each commit is an add + diff + commit + best-effort async push under bare_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.

  3. Cross-pod ownership when HOST_UID != 1000. The orchestrator runs as runAsUser: 1000 in the base; the gateway entrypoint chowns /home/egg/.egg-state to HOST_UID:HOST_GID. If a host deploys with HOST_UID != 1000, the orchestrator may hit EACCES writing the shared hostPath. The shared-volume design predates this PR and the local-dev convention has HOST_UID=1000, so this is not a regression — but the comment in orchestrator-volumes.yaml claims 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

@james-in-a-box

This comment has been minimized.

@james-in-a-box

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.
@james-in-a-box

Copy link
Copy Markdown
Contributor

Thanks for the careful review. Per-item disposition:

  1. Test parity with rule 6fixed-in-PR (commit f7ea687). Added the three missing rule-7 tests mirroring the rule-6 trio:

    • test_pipeline_state_store_rule_clean_when_both_pvc_backed — PVC repos + PVC egg-state is silent.
    • test_pipeline_state_store_rule_fires_when_pvc_repos_emptydir_state — the hypothetical cloud-overlay trap fires.
    • test_pipeline_state_store_rule_fires_on_orchestrator_dash_variantorchestrator-canary matches the exact-or-prefix scope.

    These pair 1:1 with test_session_store_rule_clean_when_both_pvc_backed, test_session_store_rule_fires_when_pvc_worktrees_emptydir_state, and test_session_store_rule_fires_on_gateway_dash_variant, so a future shared-helper refactor of both rules now has full per-rule coverage. All 8 pipeline_state_store tests pass locally (pytest orchestrator/tests/test_deployment_routes.py -k pipeline_state_store).

  2. Commit volume for prompt-driven pipelinesdeferred-to #3090. Reviewer explicitly asked for "a follow-up issue to measure once #3070 is closed, but not a reason to hold this PR" — opened perf(orchestrator): measure post-#3070 save_pipeline commit volume and latency #3090 with the measurement scope (baseline per-save latency, steady-state branch growth, hot-path identification across the 50+ save_pipeline callers in routes/pipelines.py) and an explicit out-of-scope note that re-introducing any form of force_commit is not on the table (durability must not be issue-number-keyed).

  3. Cross-pod ownership when HOST_UID != 1000fixed-in-PR (commit f7ea687). Expanded the egg-state mountPath comment in k8s/overlays/local/patches/orchestrator-volumes.yaml to flag the coupling: the shared /home/egg/.egg-state hostPath works because the gateway entrypoint chowns the parent to HOST_UID:HOST_GID, the local-dev convention is HOST_UID=1000 (which aligns with the orchestrator's runAsUser: 1000), an HOST_UID != 1000 host would EACCES here, and cloud overlays moving to PVCs (rule 7 / rule 6's emptyDir-checking already accommodates this) sidestep the chown coupling entirely.

make lint is clean for the touched files; the two pre-existing mypy errors in shared/egg_agent/client.py are unrelated to this PR (the file is untouched on this branch).

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_backed mirrors test_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_state mirrors test_session_store_rule_fires_when_pvc_worktrees_emptydir_state — same #3070 asymmetry, just with PVC in place of hostPath; fires with severity=error.
    • test_pipeline_state_store_rule_fires_on_orchestrator_dash_variant mirrors test_session_store_rule_fires_on_gateway_dash_variantorchestrator-canary matches the name.startswith("orchestrator-") branch at routes/deployment.py:947 and the warning records Deployment/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_doc already accepted a name= kwarg (no new fixture surface needed), and the rule only gates on metadata.name, not on the app label, so the dash-variant test's labels: {app: orchestrator} doesn't taint the assertion.
  • HOST_UID coupling comment (commit f7ea687, k8s/overlays/local/patches/orchestrator-volumes.yaml:71-78): The claim that gateway/entrypoint.sh chowns /home/egg/.egg-state to HOST_UID:HOST_GID is accurate — entrypoint.sh:245-249 runs chown -R "$HOST_UID:$HOST_GID" over /home/egg/.egg-state (and .egg-worktrees) before gosu-dropping. With no accompanying chmod and the orchestrator pinned at runAsUser: 1000, a HOST_UID != 1000 deploy 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

@james-in-a-box

Copy link
Copy Markdown
Contributor

egg review completed. View run logs

3 previous review(s) hidden.

@jwbron
jwbron merged commit 1a6d845 into main Jun 11, 2026
23 checks passed
jwbron added a commit that referenced this pull request Jun 11, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant