Repository navigation
Recover every running session immediately after an operator restart - #50
Conversation
The token registry is process memory, refilled after a restart only as each session was next reconciled - one at a time on the single AgentSession worker - while the memory API answered unknown bearers 'retry' for a fixed 60s from process birth and 'refused' after. Leader election and the cache sync spent much of that window, and a live session still queued behind it failed for good. The registry now starts in a restoring state, a runnable restores every non-deleting session's token as soon as the caches sync (on every replica, without waiting for the lease), and only then does an unknown bearer get a 401. The memory and goal APIs both ask the registry instead of a clock, and AgentSession reconciles wait for the restore so a finalize cannot revoke a token the restore is about to put back.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
samkim
left a comment
There was a problem hiding this comment.
Review notes (code review plus a data-security pass, each finding re-checked against the head commit and against the PR description).
Should fix before merge
1. Workshop sidecar bearers lose the removed 60s grace and get a terminal 401 after a restart (pkg/controllers/agentsession/tokenrestore.go, pkg/memory/httpsrv/httpsrv.go)
The old cold-start grace also covered Workshop sidecar tokens. Those are only re-registered by the Workshop reconciler (pkg/controllers/workshop/controller.go:496-497), which is leader-only, and the new restore pass doesn't include them. The pass ends the restoring state about a second after cache sync, but with --leader-elect and no LeaderElectionReleaseOnCancel, the new pod can wait up to the lease duration before the Workshop reconciler runs. In that window the sidecar (internal/cmd/workshop/main.go hitting /memory/...) gets a 401 instead of the retryable 503 it got before. This contradicts "sessions are told to retry rather than refused" and "refused exactly as before"; the description doesn't mention Workshops.
Suggested fix: restore Workshop tokens in the same pass (list Workshops, read their bearer Secrets), or keep the registry restoring until they are registered too.
2. A failed Secret read during the pass is swallowed and the restore still finishes (tokenrestore.go:117-126, memorytoken.go:74-85)
One transient Get failure is logged, FinishRestore runs anyway, and that session gets terminal 401s until its next reconcile. That contradicts "every running session is recovered". Suggested fix: collect failures and retry them before FinishRestore, or have reregisterMemoryToken return an error the runnable retries on.
3. "However long the pass takes" overstates the client side. The 503 is only retried within the memory httpclient's ~17s budget (pkg/memory/httpclient/httpclient.go:411-420, 8 attempts), so a request that arrives early and outlasts cache sync plus the pass still fails. Not a regression (the old code had the same limit and this PR shortens time-to-restore), but either reword the description or have the client honor Retry-After and keep retrying on this specific 503.
Minor / follow-up
- Multi-replica (latent): the restore runs on every replica (
NeedLeaderElection() false), butRevokeonly runs in the leader's finalize. A standby now holds tokens for sessions that are later deleted and keeps accepting them until it restarts; before this PR its registry was empty. The default install is 1 replica with Recreate, so this isn't reachable today. If multi-replica serving is intended, serve the memory API only on the leader or propagate revocations. - If ctx is cancelled mid-pass, every Get fails, the errgroup returns nil, and
FinishRestoreruns on a partial registry. Checkctx.Err()first. - The
sessionscount in "memory token registry restored" counts sessions examined, not tokens registered. - The "unknown bearer while restoring → 503" branch is duplicated in
httpsrvandgoals; a shared helper would keep them in step.
Checked and clean
No revoked token is resurrected (deleting sessions are skipped, and Reconcile blocks on WaitRestored, so no finalize runs between the List and the Set). No wrong-session binding (reuses reregisterMemoryToken, never mints). While restoring, an unknown bearer gets 503, never access. No token material in logs, no RBAC change.
…hing The startup restore covered only AgentSession runner tokens. A Workshop sidecar's bearer came back only when the leader-only Workshop reconciler next ran, which after a restart can be a whole lease later, by which time the registry had stopped answering "retry" and refused the sidecar for good. The restore pass now lives in pkg/controllers/tokenrestore and runs one Restorer per bearer owner (AgentSession and Workshop), wired in the operator. The registry leaves the restoring state only once every Restorer has succeeded: a Secret read that fails is now returned rather than logged and dropped, so the pass is retried instead of finishing with that session missing, and a pass cut short by shutdown never declares the registry complete. The Workshop reconciler waits for the restore the same way the AgentSession reconciler does, so a teardown's revoke cannot be undone by it. The "retry while restoring" answer is shared between the memory API and the goal API, and the restore log now counts tokens registered rather than sessions examined.
|
Thanks — addressed in 6a417e4:
Minor: cancellation mid-pass no longer finishes the restore, the log counts tokens registered, and the 503 branch is a shared |
The startup restore reads each session's memory-token Secret through the adoptguard reader, which panics in the default --secret-guard-mode=panic when the Secret exists without the adopted label. Inside Reconcile, controller-runtime recovers that and fails one session. The restore runs on errgroup goroutines under a manager Runnable instead, where nothing recovers it, so one unadopted Secret crashed the operator on every boot, before any reconcile ran. Recover in each per-session goroutine and around each Restorer call. A panic is logged at Error and that session (or restorer) is skipped, not failed: it would recur on every retry and hold every AgentSession and Workshop Reconcile behind WaitRestored. The skipped session is left to its own Reconcile, as before the restore pass existed. For errors that keep failing without panicking, stop retrying after five minutes: declare the registry restored and log the failure at Error, so reconciles are not held forever. Each owner's Reconcile still re-registers a bearer the pass missed. Also read Registry.Restoring before the token lookup in the memory API and the goals API, not after a miss. Read after, a restore that registers the bearer and finishes in between turned the miss into a permanent 401 for a token that had just become valid. RetryWhileRestoring now takes that snapshot, and Restoring is nil-safe for callers with no registry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The give-up deadline was checked only after a pass returned, so a live Secret GET hung on a stalled apiserver connection kept the pass from ever returning and every Reconcile waited in WaitRestored forever. Run each pass under a context bounded by what is left of the give-up budget. A Forbidden Secret read will not clear on retry, so treating it as transient held every AgentSession and Workshop Reconcile for the full give-up window on each boot. Log it at Error and skip that session or Workshop, as a panic already is; its own Reconcile re-registers the bearer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The fixes in 6a417e4 cover everything from the first review. A re-review turned up one new blocker and a few related problems, so I pushed two commits on top: b2767e0 and c9e753f. Blocker: one unadopted Secret crashed the operator on every boot (b2767e0)The restore reads each session's memory-token Secret through the
Retries that block reconciles too long (b2767e0, c9e753f)Every Reconcile waits on the restore, so a pass that never succeeds needs a bound:
Token lookup race in the memory and goals APIs (b2767e0)
Both now read Tests
Follow-ups I left for you (not blocking)
|
Summary
When the operator restarted, running sessions lost access to their memory until the operator got around to each one individually. Sessions were recovered one at a time, while a fixed one-minute window counted down from the moment the process started; any running session not reached before that window ran out was failed permanently, even though nothing was actually wrong with it.
Now every running session, and every running workshop's sidecar, is recovered in a single pass as soon as the operator is ready, before it starts doing any other session work. Until that pass completes, unrecognized credentials are told to retry rather than refused; if part of the pass fails, it is retried rather than finished without the missing credentials. Once it completes, an unrecognized credential is refused exactly as before.
Clients still retry within their existing retry budget, so a request that arrives very early in a slow restart can still time out, as before — but the pass now finishes far sooner than sessions used to be reached one by one.
Test plan