Skip to content

Recover every running session immediately after an operator restart - #50

Merged
josephschorr merged 4 commits into
mainfrom
token-registry-rebuild
Nov 5, 2026
Merged

josephschorr merged 4 commits into
mainfrom
token-registry-rebuild

Conversation

@josephschorr

@josephschorr josephschorr commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

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

  • Unit tests for the recovery pass: running sessions and ready workshops recovered with the same access they had; deleting ones and ones without stored credentials skipped; a failed read reported and retried without blocking the rest
  • Unit tests that recovery is only declared complete once every part succeeds, and never when cut short by shutdown
  • Unit tests for the retry-versus-refuse behavior before, during, and after recovery, for both affected services
  • Unit, integration and e2e suites

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.
@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
openagentprimitives Ignored Ignored Preview Oct 30, 2026 11:08pm UTC

Request Review

@samkim samkim left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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), but Revoke only 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 FinishRestore runs on a partial registry. Check ctx.Err() first.
  • The sessions count in "memory token registry restored" counts sessions examined, not tokens registered.
  • The "unknown bearer while restoring → 503" branch is duplicated in httpsrv and goals; 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.
@josephschorr

Copy link
Copy Markdown
Member Author

Thanks — addressed in 6a417e4:

  1. Workshop sidecar bearers are now restored in the same startup pass (the restore is a shared runnable with one restorer per owner), and the Workshop reconciler waits on it like AgentSession does.
  2. A failed Secret read is now returned instead of swallowed; the pass is retried and the registry stays restoring until every token is back.
  3. Reworded the description rather than changing the client — an unbounded retry on this 503 could hang runners if a restore never finishes.

Minor: cancellation mid-pass no longer finishes the restore, the log counts tokens registered, and the 503 branch is a shared tokens.RetryWhileRestoring. Multi-replica revocation left as a follow-up since it isn't reachable with the single-replica install.

samkim and others added 2 commits October 30, 2026 15:33
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>
@samkim

samkim commented Oct 30, 2026

Copy link
Copy Markdown
Member

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 adoptguard reader. With the default --secret-guard-mode=panic, that reader panics when the Secret exists but lacks the adopted label (adoptguard/guard.go:69). Inside Reconcile, controller-runtime recovers the panic and only that session fails. The restore runs that same read on errgroup goroutines under a manager Runnable, where nothing recovers it. So a single unadopted <session>-memory-token Secret crash-looped the operator before any reconcile ran. That Secret could have been pre-created under that name, or had its label removed.

  • Each restore goroutine (agentsession.restoreOne) and each restorer call (tokenrestore.restore) now recovers the panic.
  • A panic is logged at Error and that session is skipped, not returned as an error. The guard panics on the same Secret every time, so retrying would only hold every AgentSession and Workshop Reconcile behind WaitRestored. A skipped session is handled by its own Reconcile, as it was on main.
  • A regression test uses the real guard in panic mode. With the recovery removed, it crashes the test binary with the same adoptguard panic.

Retries that block reconciles too long (b2767e0, c9e753f)

Every Reconcile waits on the restore, so a pass that never succeeds needs a bound:

  • Give-up deadline: after 5 minutes (defaultGiveUpAfter), the registry is declared restored and the failure is logged at Error. Each owner's Reconcile still re-registers any bearer the pass missed. Change the value if you want a different bound.
  • Each pass is bounded too: the deadline was only checked after a pass returned, so a Secret GET hung on a stalled apiserver connection kept the pass from returning and reconciles waited forever. Each pass now runs under a context bounded by what's left of the give-up budget.
  • A Forbidden read is skipped like a panic. It won't clear on retry, so it shouldn't hold reconciles for the whole window. Both the AgentSession and Workshop restorers do this.

Token lookup race in the memory and goals APIs (b2767e0)

httpsrv and goals checked Restoring() only after a token lookup missed. If the restore registered that token and finished between those two steps, a now-valid token got a permanent 401, and the session failed with MemoryUnavailable. The goals comment said its single snapshot prevented this, but Restoring() was a separate read.

Both now read Restoring() before the lookup. tokens.RetryWhileRestoring takes that snapshot as a bool, and Registry.Restoring() is nil-safe. There's no dedicated test for this timing, because forcing it would need a test-only hook in the handler. The existing 503/401 tests pass.

Tests

  • go test -race passes twice in a row on controllers/{tokenrestore,agentsession,workshop}, memory/{tokens,httpsrv}, web/goals and internal/cmd/operator.
  • go vet is clean on those packages with no tags, with -tags=integration and with -tags=e2e.
  • -tags=integration (envtest 1.30.3) passes on the six pkg/ packages.
  • I haven't run the full mage test:integration / mage test:e2e locally; CI should cover them now.

Follow-ups I left for you (not blocking)

  • Workshop routes still return 401 during the restore. workshopthreadsrv, workshoptranscriptsrv, workshopdraftsrv, workshopprojectsrv and secretoutsrv still answer an unknown bearer with a plain 401 while restoring. That was true on main too, but it means Workshop bearers are restored while the routes sidecars actually call still refuse them in that window. Putting the "retry while restoring" check inside the shared registry lookup, rather than in each handler, would cover them all. Otherwise, scope the description's "unrecognized credentials are told to retry" to /memory/ and goals. This came up in all three reviews.
  • Multi-replica. A non-leader replica restores once and never sees later Revokes, which are leader-only. It's latent at replicas: 1, but the Runnable comment ("every replica needs it restored") reads as if multi-replica already works.
  • Reconcile workers. AgentSession and Workshop Reconcile hold their worker while blocked in WaitRestored. Requeuing with a delay while Restoring() would free the workers.
  • Workshop restore duplicates step 8. It re-implements step 8's key, Secret name and Set (controller.go:503-506), and the two already differ: step 8 registers an empty token, the restore skips it. A shared helper, like reregisterMemoryToken on the AgentSession side, would keep them in sync.
  • The Workshop restore reads Secrets one at a time, while the AgentSession restore reads 16 at once. On a large install this lengthens the restoring window.
  • Pass cost on large installs. The AgentSession pass GETs one Secret per AgentSession ever created, terminal ones included, and every Reconcile waits for the whole pass.
  • No test that AgentSession Reconcile waits on WaitRestored. Workshop has TestReconcile_WaitsForTokenRestore.

@josephschorr
josephschorr merged commit 7d3d813 into main Nov 5, 2026
9 of 10 checks passed
@josephschorr
josephschorr deleted the token-registry-rebuild branch November 5, 2026 16:19
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.

2 participants