feat(#3278): persist Claude session per (pipeline,slice,role) across event pods - #3286
Conversation
…event pods Make BRC warm resume (#3200) actually fire. claude --resume reads a session's local transcript ($CLAUDE_CONFIG_DIR/projects/<cwd-slug>/<session_id>.jsonl); under the orchestrator-owned event loop (#3164) every event is a fresh pod, so that transcript dies with the pod and persisting only the session_id+occupancy pointer (the original #3276 scope) would cold-start every event while the gate records below_threshold "resume". This carries the transcript across pods. The sandbox never writes host state: the durable copy is orchestrator-owned in Redis (one TTL'd key per (pipeline,slice,role) holding pointer + transcript together), reached only over the controlled API. The live transcript stays on the pod's ephemeral CLAUDE_CONFIG_DIR; the wrapper pulls it in before the agent (so --resume finds a real session) and pushes the updated session back after. - session_state_store.py: Redis store (TTL, size-guarded, best-effort). - routes/session_state.py: GET/POST /api/v1/pipelines/<pid>/session-state. - egg_lib/{session_state_sync,cli_session_state}.py: egg-orch session-state pull|push; slug = re.sub(r'[^a-zA-Z0-9]','-', abspath(cwd)) (spike-verified). - concurrent_executor.py: inject CLAUDE_CONFIG_DIR + EGG_SESSION_STATE_FILE into event pods, gated on warm resume enabled (default pods byte-identical). - consensus_wrapper.py: pull-before/push-after, agent rc preserved; golden updated. - docs/architecture/context-discipline.md: cross-pod persistence section. The egg_agent substrate (session.py/reseed.py/__main__.py) is untouched. Stacked on #3279 (reseed-threshold injection); together they complete the #3276 substrate.
This comment has been minimized.
This comment has been minimized.
…n-store-substrate: resolve conflict in orchestrator/tests/test_concurrent_executor.py Additive conflict: base added test_event_spawn_threshold_conservative_for_unregistered_litellm_model to TestEventSpawnReseedThreshold; PR added the TestEventSpawnSessionStoreEnv class. Kept both.
Conflict Resolution SummaryResolved merge conflicts with
Detail: Both branches added new tests at the same location:
These are non-overlapping additions. Resolution keeps the base's new method inside Verification:
Please review: — Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Autofix tracking{"Lint/Python": 1} |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
No agent-mode design concerns. Approving on design alignment.
This is cross-pod session-persistence infrastructure, and it embodies the agent-mode principles rather than fighting them:
- Sandbox-as-constraint (core principle): the durable copy is orchestrator-owned in Redis and reached only over the controlled
/session-stateroute — the sandbox never writes host state. This is the right way to add a capability: extend the controlled surface, not the prompt. - Not pre-fetching: the transcript that crosses pods is the agent's own accumulated session (
claude --resumeinput), persisted so continuity survives one-shot pod death. That preserves the agent's context rather than a third party deciding what's relevant and baking it into a prompt — the opposite of the pre-fetch anti-pattern, and consistent with context-discipline. - No post-processing pipeline: pull/push is session plumbing the agent cannot do itself (no host write access by design), not a script parsing agent output to take actions the agent could take directly.
- Agent SDK preserved: invocation stays
python3 -m egg_agentwith theopusalias; no direct Anthropic API calls, no pinned model IDs, default pod path byte-identical.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review — #3286: persist Claude session per (pipeline, slice, role) across event pods
Thorough pass over all 15 files, tracing the cross-pod round-trip end-to-end and the cross-module contracts it depends on. No blocking issues. The design is sound, the default path is genuinely byte-identical (gated off), and error handling is best-effort throughout. A few non-blocking suggestions below.
What I verified (load-bearing assumptions that hold)
except A, B:/except A, B, C:are correct, not Python-2 bind syntax.session_state_store.py:188(except ValueError, TypeError:) andsession_state_sync.py:142(except OSError, ValueError, TypeError:) initially read like the removed Py2except E, name:form. They are valid PEP 758 unparenthesized except groups:requires-python = ">=3.14", CI/Dockerfiles pin 3.14, and the sandbox setsupdate-alternatives python3 → 3.14. Confirmed by compile + runtime that all listed types are caught, and the form is already an established convention here (env_config.py,agent_salvage.py,redis_message_store.py, …). Not a bug.- The slug matches Claude Code's project dir on both sides.
session_state_sync.resolve_repo_pathdefaults toEGG_REPO_PATH, and the agent's SDK cwd resolves to the same (shared/egg_agent/client.py:284:cwd if cwd else EGG_REPO_PATH). So<config_dir>/projects/<slug>/<sid>.jsonlwritten bypullis where--resumereads. (The in-cluster--resumecall itself is still unverified, as the PR discloses — but the slug math is consistent across the changeset, so this is not the cross-module dead-end the feature exists to prevent.) - Pointer format matches the consumer.
write_pulled_statewrites{"session_id", "window_occupancy"}, exactly whategg_agent.session.read_session_statereads (andwrite_session_stateproduces), sopush/pullround-trip cleanly through the untouched substrate. - Agent rc is preserved.
consensus_wrapper.pycaptureslocal _agent_rc=$?immediately after the agent command (before the push) andreturn "$_agent_rc"; the call site exits with it. The post-agent push cannot mask the agent's exit code. - Default pods stay byte-identical. Env injection is gated on
session_resume_enabled()(orchestrator-side), and the wrapper's pull/push gate onEGG_SESSION_STATE_FILEpresence — both inert on the default path. Test coverage (TestEventSpawnSessionStoreEnv) confirms. - Defensive contract is real. Oversized transcript drops to pointer-only (
MAX_TRANSCRIPT_BYTES), malformed payload / Redis-down collapse toNone/False→ safe cold reseed. No FlaskMAX_CONTENT_LENGTHis set, so the 32 MiB POST is accepted. Tests exercise the production helpers (not hand-built fixtures), and the route tests round-trip through the real store.
Non-blocking suggestions
-
session_idis interpolated into a filesystem path without format validation.transcript_path()builds…/projects/<slug>/{session_id}.jsonland bothpull(write) andpush(read) use it.session_idoriginates from the Claude SDK (a UUID) within the same trust domain, so this is not an exploitable escalation — but a../-bearing or empty-after-strip value would escape the project dir. A cheap UUID/token regex guard before the path join would be defense-in-depth, and would also harden the Redis→pull direction. -
Inner HTTP timeout vs. payload size. Both
pullandpushcallorch_request(...)with its defaulttimeout=15s, while the transcript can be up to 32 MiB. The wrapper'stimeout 60is only the outer process bound; the HTTP layer gives up at 15s and silently degrades to a reseed. On a slow link a large transcript could reseed every event without signal. Consider passing a largertimeouttoorch_requestfor the transcript calls, given the payload class. -
SessionStateStore.delete()is defined but never wired into any teardown. Reaping relies solely on the 6h TTL (which the docstring frames as intentional). Either hookdelete()into slice/pipeline teardown, or drop it as dead code — right now it's unreferenced. -
set_session_state_storeis missing from__all__. It's exported for tests (and sits besidereset_session_state_store, which is in__all__). Minor consistency nit. -
Perf (already author-acknowledged): the transcript round-trips on every event even when the gate reseeds. Worth the disclosed follow-up to skip the round-trip on the reseed path.
Net: solid substrate PR, well-tested, safely gated. The items above are all non-blocking.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- Validate session_id (UUID/token shape) before interpolating into the transcript filesystem path; reject traversal/separators in both pull and push directions (defense-in-depth). - Give the transcript round-trip a payload-sized 45s HTTP timeout instead of orch_request's 15s default, under the wrapper's outer timeout 60 bound. - Drop unused SessionStateStore.delete() (TTL is the intended reaping path) and its test. - Export set_session_state_store in __all__ for consistency.
Review feedback addressedThanks for the thorough pass. All four actionable non-blocking suggestions are fixed in-PR (commit
On the earlier conflict-resolution mypy noteThe mypy errors flagged in Targeted tests green (37 passed across the three suites); — 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.
There was a problem hiding this comment.
Re-review (delta b56db56..f84611d4) — No agent-mode design concerns. Re-approving on design alignment.
The only delta since my prior approval is the author's response to the base review bot's non-design suggestions, and none of it shifts the agent-mode posture:
is_safe_session_id()path guard — a^[A-Za-z0-9][A-Za-z0-9_-]*$validation on the session id before it's joined into…/projects/<slug>/<sid>.jsonl. This is filesystem-path defense-in-depth at the code layer (raisesValueError/ returnsFalse→ safe reseed), not prompt-level security standing in for sandbox enforcement. It hardens the off-pod Redis→pull direction, which is the right place to validate untrusted-origin data._SESSION_STATE_HTTP_TIMEOUT = 45— timeout tuning sized to the 32 MiB payload class, kept under the wrapper's outertimeout 60. Plumbing, no design impact.delete()dropped /set_session_state_storeadded to__all__— dead-code removal and export-consistency nits.
The load-bearing design properties from my prior pass are intact: invocation stays on the Agent SDK (python3 -m egg_agent, opus alias — no raw Anthropic calls, no pinned model IDs), the durable copy remains orchestrator-owned in Redis over the controlled /session-state route (sandbox-as-constraint), what crosses pods is the agent's own accumulated session rather than third-party pre-fetched context, and the default pod path stays gated/inert. No structured-output-for-humans, post-processing, or rigid-procedure patterns introduced.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review — #3286: session-store substrate (delta b56db56..f84611d4)
Re-reviewed the single new commit f84611d4 ("Address review: harden session_id path, timeout, drop dead delete()") that landed since my last pass. All four actionable suggestions from the prior review are properly addressed, not just superficially. Traced each end-to-end. No blocking issues; approving.
Verified fixes
-
session_idpath validation (suggestion 1).is_safe_session_id()uses\A[A-Za-z0-9][A-Za-z0-9_-]*\Zviafullmatch. Airtight against traversal:.is excluded entirely (so..is impossible),/and\excluded, empty rejected. Enforced as a single chokepoint intranscript_path()(raisesValueError) and early-reject in both directions —write_pulled_state(pull) andread_state_for_push(push) bail before building any path. Both in-module callers pre-validate, so theValueErroris pure defense-in-depth with no unhandled path (theexcept OSErrorblocks don't need to catch it).test_pull_rejects_unsafe_id_without_escapingasserts no pointer/projects files are written for a../evilid. Good — the Redis→pull direction (where the value originates off-pod) is now hardened as requested. -
HTTP timeout vs. payload (suggestion 2).
_SESSION_STATE_HTTP_TIMEOUT = 45is passed toorch_request(signature acceptstimeout: int = 15) on both pull and push, sized to the 32 MiB payload class and sitting under the wrapper'stimeout 60outer process bound (consensus_wrapper.py:261/266), so the HTTP layer — not the kill — bounds a stuck call. Asserted intest_cli_session_state.py. -
Dead
delete()(suggestion 3). Removed along with its test. No dangling callers anywhere inorchestrator/orsandbox/. TTL remains the documented reaping path, which is a reasonable disposition. -
set_session_state_storeexport (suggestion 4). Added to__all__besidereset_session_state_store; consumed by the route tests. -
Perf round-trip (suggestion 5). Appropriately deferred to filed follow-up #3291 — skipping the round-trip on the reseed path needs in-cluster measurement since the reseed decision is the agent's runtime call. Reasonable to punt.
Test quality
The new TestSafeSessionId tests exercise the production helpers directly (is_safe_session_id, transcript_path, write_pulled_state, read_state_for_push) — no hand-built fixtures bypassing the production path, no self-seeding goldens. Ran them locally: 6 passed (TestSafeSessionId + TestRoundTrip).
No new issues introduced by the delta.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
| egg is addressing review feedback... |
Review feedback — already addressed in current HEADThe two most recent reviews are re-reviews on the current HEAD (
No code changes are needed in this round — the working tree is clean and HEAD ( — Authored by egg |
|
egg feedback addressed. View run logs 12 previous review(s) hidden. |
5e69a60
into
egg/3279-reseed-threshold-injection
What
Make BRC warm resume (#3200) actually fire by persisting the Claude Code session per
(pipeline, slice, role)across the one-shot event pods — the durable copy is orchestrator-owned and reached only over the controlled API, so the sandbox never writes host state.Stacked on #3284 (#3279, the reseed-threshold injection); the two together complete the #3276 substrate.
Why the pointer alone wasn't enough
claude --resume <session_id>re-enters a session by reading its local transcript ($CLAUDE_CONFIG_DIR/projects/<cwd-slug>/<session_id>.jsonl). Under the orchestrator-owned event loop (#3164) every BRC event is a fresh pod, so that transcript dies with the pod. Persisting only thesession_id+occupancypointer (the original #3276 scope) would let the gate recordbelow_threshold"resume" decisions while every event silently cold-starts. The transcript itself has to cross pods.Spike (against the installed Claude Code build) confirmed:
CLAUDE_CONFIG_DIRrelocates the whole session store, and the project slug isre.sub(r'[^a-zA-Z0-9]', '-', abspath(cwd))(e.g./home/egg/repos/My_Repo.v2→-home-egg-repos-My-Repo-v2).Design (constraint: sandbox never writes host state)
$CLAUDE_CONFIG_DIRis the pod's own filesystem (no host mount); Claude Code writes the transcript locally during the run, unchanged.session_state_store.pykeeps one TTL'd key per(pipeline, slice, role)holding the pointer and transcript together (no split-brain; TTL reaps abandoned state). Only the orchestrator writes it.egg-orch session-state pull(before the agent) re-materialises the prior transcript + pointer into the pod so--resumefinds a real session;egg-orch session-state push(after) ships the updated session back. Both best-effort andtimeout-bounded; a failed sync degrades to a safe cold reseed.The existing
egg_agentsubstrate (session.py/reseed.py/__main__.py) is untouched — it round-trips the pointer via$EGG_SESSION_STATE_FILEexactly as before; this PR adds the transcript layer around it.Changes
orchestrator/session_state_store.py— Redis store (TTL, size-guarded, best-effort).orchestrator/routes/session_state.py—GET/POST /api/v1/pipelines/<pid>/session-state(registered inapi.py).sandbox/egg_lib/session_state_sync.py— slug + transcript/pointer file I/O (pure, unit-tested).sandbox/egg_lib/cli_session_state.py—egg-orch session-state pull|push(best-effort).concurrent_executor.py— injectCLAUDE_CONFIG_DIR+EGG_SESSION_STATE_FILEinto event pods, gated on warm resume being enabled (default pods byte-identical).consensus_wrapper.py— pull before / push after the agent invocation, preserving the agent's exit code; golden snapshot regenerated.docs/architecture/context-discipline.md— documents the cross-pod persistence layer.Tests
New: store (TTL/scoping/defensive), route (round-trip/validation), sync logic (slug/pull/push/round-trip), CLI (best-effort + orchestrator round-trip), env-injection gating. Wrapper golden updated and re-verified.
make testgreen (the 2test_reap_stale_egg_imagesfailures are pre-existing btrfs-host env noise, #3222).Verification still owed in-cluster
--resumeend-to-end across two real event pods (the spike confirmed the file mechanics + relocation locally; the resume call itself needs the in-cluster gateway/API-key auth). Also worth measuring transcript transfer cost per resumed event (a refinement: skip the round-trip on the reseed path).Related
Closes #3278