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
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).
Problem
Session memory (per-task conversation state) is persisted locally as
.forge/sessions/<taskID>.jsonviaMemoryStore(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: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.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
SessionStoreinterface fromMemoryStore(theSave/Loadseam already exists —loop.gocallsLoad(taskID)on task start andSave(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 bytaskID. Stateless pods; any-pod resume; no PVC.Interface
Concurrency model (the part to get right)
SessionDatais bounded by context-window + the compactor summary, so per-turn full snapshots are fine as a first cut.GET /sessions/{taskID}withIf-None-Match: <local-etag>->304(use local cache) or200(pull fresh). The LLM always runs on current state — no post-turn merge, no re-running an expensive turn on conflict.taskIDis first touched on this pod. A cold pod is instantly ready and doesn't fetch thousands of sessions.Save(expected=V)commits viaIf-Match/WHERE version=V. Costs nothing on the happy path; on the rare overlap it turns a silent lost-update into a detectable conflict.Cross-cutting
(org, agent, taskID).remotebackend 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
SessionStoreinterface extracted;filebackend reproduces current behavior exactly (default; local.forge/sessions).remotebackend: conditional GET before turn (304/200), lazy per-task pull, snapshot-upsert by taskID.Savewith a stale expected version returnsErrConflict; the loop handles it (rebase-or-yield, never re-runs the LLM).memory.session_store: remote+ endpoint), with redaction-aware content handling.Conformance/tests
TestSessionStore_FileBackendUnchanged,TestRemoteSessionStore_AnyPodResume,TestRemoteSessionStore_ConditionalGetFreshness,TestRemoteSessionStore_SaveConflictOnStaleVersion.Out of scope
initializ/console-next(session store + version/CAS API, tenant-keyed), to be filed separately..forge/ctxzip.db) — stays pod-local; it's a rebuildable performance cache with expiring offloaded originals, no reason to share.MEMORY.md+ index) — separate concern.References
SessionData/MemoryStore(forge-core/runtime/memory_store.go); loop seamstore.Load/store.Save(forge-core/runtime/loop.go).