Skip to content

Remote session store — stateless multi-pod session memory (conditional-GET + CAS), no PVC #243

Description

@initializ-mk

Problem

Session memory (per-task conversation state) is persisted locally as .forge/sessions/<taskID>.json via MemoryStore (forge-core/runtime/memory_store.go). The loop loads it on task start and saves it per turn. This forces a stateful-volume dependency for any durability or multi-pod deployment:

  • PVC-bound. Cross-restart durability needs a PVC. Multi-replica needs ReadWriteMany (limited/expensive on most clouds), and even then session recovery is a per-pod-filesystem model — a task can only meaningfully resume on the pod that holds its file, and concurrent writers to the same volume aren't the design intent.
  • No portability. A task can't resume on an arbitrary pod; pods can't be truly stateless (blocks clean rolling deploys, spot nodes, horizontal scale).

We want stateless agent pods that can resume any task on any replica without a PVC, by pushing session state to a remote store and pulling it on demand.

Proposed control

Extract a pluggable SessionStore interface from MemoryStore (the Save/Load seam already exists — loop.go calls Load(taskID) on task start and Save(data) per turn), with two backends:

  • file (default, unchanged) — today's local .forge/sessions/*.json. Single-pod / dev.
  • remote (opt-in) — a version-aware remote store keyed by taskID. Stateless pods; any-pod resume; no PVC.

Interface

type SessionStore interface {
    // Version is store-owned (ETag / monotonic counter). Load is a conditional
    // read: when localVersion matches, the store may return NotModified.
    Load(ctx, taskID string, localVersion Version) (*SessionData, Version, error)
    // Save commits only if the store's current version == expected; else ErrConflict.
    Save(ctx, taskID string, data *SessionData, expected Version) (Version, error)
}

Concurrency model (the part to get right)

  • Snapshot-upsert keyed by taskID, not a log. SessionData is bounded by context-window + the compactor summary, so per-turn full snapshots are fine as a first cut.
  • Hot path = conditional GET before the turn. Before running the LLM, validate freshness: GET /sessions/{taskID} with If-None-Match: <local-etag> -> 304 (use local cache) or 200 (pull fresh). The LLM always runs on current state — no post-turn merge, no re-running an expensive turn on conflict.
  • Lazy pull, never eager. On restart, pull nothing; pull a session only when its taskID is first touched on this pod. A cold pod is instantly ready and doesn't fetch thousands of sessions.
  • Write-time CAS backstop (near-free). Save(expected=V) commits via If-Match / WHERE version=V. Costs nothing on the happy path; on the rare overlap it turns a silent lost-update into a detectable conflict.
  • Why this is enough: A2A serializes turns within a task (turn N+1 arrives after turn N's response), so there is normally one writer per task at a time. K8s gives single-request routing, not single-turn-in-flight — the residual overlap window is caller retries / mesh-retry policies / at-least-once orchestrators / the rolling-deploy drain window (pod SIGTERM'd mid-turn -> caller retries -> new pod). The CAS covers that window.
  • Inbound idempotency (deferrable layer). Only needed when retrying/at-least-once callers are present: dedup a turn by idempotency key at request entry so a duplicate delivery doesn't double-run the model. Defer until such callers exist; the CAS still prevents the lost-update meanwhile.

Cross-cutting

  • Auth: the pod authenticates to the remote store with its workload identity (SPIFFE/OIDC — same as the certifier work), no new static secret.
  • Tenancy: keyed by (org, agent, taskID).
  • Privacy: session content is the full conversation -> opt-in, default-off, redaction-aware, matching the FWS-8 audit-payload-capture posture. Shipping session content off-box is a deliberate data-egress decision.
  • Backend-agnostic: the remote backend is itself pluggable (platform session service / DynamoDB / Firestore / Postgres / Redis / S3), each supplying the CAS primitive (If-Match -> 412, conditional write, WATCH/MULTI, etc.).

Acceptance criteria

  • SessionStore interface extracted; file backend reproduces current behavior exactly (default; local .forge/sessions).
  • remote backend: conditional GET before turn (304/200), lazy per-task pull, snapshot-upsert by taskID.
  • Write-time CAS: Save with a stale expected version returns ErrConflict; the loop handles it (rebase-or-yield, never re-runs the LLM).
  • A task started on pod A resumes correctly on pod B with no shared filesystem (integration test with two store clients + one backend).
  • Off by default; enabling is explicit (memory.session_store: remote + endpoint), with redaction-aware content handling.
  • Pod authenticates to the store via workload identity, not a static secret.

Conformance/tests

  • TestSessionStore_FileBackendUnchanged, TestRemoteSessionStore_AnyPodResume, TestRemoteSessionStore_ConditionalGetFreshness, TestRemoteSessionStore_SaveConflictOnStaleVersion.

Out of scope

  • The platform session service itself (the remote endpoint) — companion issue in initializ/console-next (session store + version/CAS API, tenant-keyed), to be filed separately.
  • The ctxzip compression store (.forge/ctxzip.db) — stays pod-local; it's a rebuildable performance cache with expiring offloaded originals, no reason to share.
  • Long-term memory vector store (MEMORY.md + index) — separate concern.
  • Inbound request idempotency beyond the deferral note above — its own change if/when retrying callers arrive.

References

  • SessionData / MemoryStore (forge-core/runtime/memory_store.go); loop seam store.Load / store.Save (forge-core/runtime/loop.go).
  • Relates to the platform identity/certifier work (workload-identity auth) and the audit-payload-capture privacy posture (FWS-8).

Metadata

Metadata

Assignees

Labels

forge-coreAffects the forge-core library (runtime, security, types, llm, mcp, auth)platformInitializ Platform (governance control plane) responsibility

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions