Skip to content

spec: reconciler token refresh for stale OIDC bug - #470

Draft
bsquizz wants to merge 2 commits into
mainfrom
spec/reconciler-token-refresh
Draft

spec: reconciler token refresh for stale OIDC bug#470
bsquizz wants to merge 2 commits into
mainfrom
spec/reconciler-token-refresh

Conversation

@bsquizz

@bsquizz bsquizz commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds specs/platform/reconciler-token-refresh.spec.md — spec for fixing sessions stuck in Creating phase due to stale OIDC tokens
  • Registers the new spec in specs/index.spec.md

Problem

execAfterReady receives a *sdkclient.Client with a static bearer token captured at provision time. The sandbox readiness polling loop can block for up to 600s (ndots retries, image pulls, scheduling delays). If the OIDC token expires during this wait, UpdateStatus(phase=Running) fails with HTTP 401 and the session is orphaned in Creating forever.

Observed on 2026-07-31: session dasfadsfdsa hit 3 ndots retries (~26s delay) which pushed the reconciler past the 5-minute token TTL. All subsequent API calls failed with 401.

Fix (specified, not yet implemented)

  • Change execAfterReady to accept projectID instead of *sdkclient.Client
  • Re-acquire the SDK client via r.factory.ForProject(ctx, projectID) before each API call
  • OIDCTokenProvider.Token() already handles lazy refresh — just needs to be called at the right time

Related

Test plan

  • Spec review: requirements cover the three API call sites in execAfterReady (Running, Failed, Completed)
  • Spec review: scenarios cover token-expired, token-valid, and token-refresh-failure cases
  • Implementation PR will add unit test for stale-token scenario

🤖 Generated with Claude Code

@jsell-rh

Copy link
Copy Markdown
Collaborator

🤖 Amber Reviewspec: reconciler token refresh for stale OIDC bug

This is a well-reasoned spec that correctly diagnoses the root cause (stale OIDC token embedded in a long-lived *sdkclient.Client inside execAfterReady). The proposed fix — calling r.factory.ForProject() before each SDK call rather than passing sdk as a parameter — is the right directional approach. Several spec gaps would lead to an incomplete implementation PR.

Overall: changes-requested — the implementation notes table is missing call sites, and two other issues warrant clarification before this spec guides implementation.

Findings (5)

🔴 Critical — Missing call site: sdk.Sessions().Get() at ~line 1082

File: specs/platform/reconciler-token-refresh.spec.md — Implementation Notes table (~line 143)

The table lists three call sites for the SDK client (lines 949, 783 via failSession, and "1059+" for completion marking), but omits the sdk.Sessions().Get(context.Background(), sessionID) call at approximately line 1082 that re-reads stop_on_run_finished after the entrypoint exec stream finishes. An implementer navigating only by this table will leave this call unpatched — producing a compile error once sdk is removed from scope, or a silently stale client if they wire the factory in the wrong spot.

Add this call site to the table.


🔴 Critical — Missing call site: sdk.Sessions().Get() at ~line 1057 (exec-retry loop)

File: specs/platform/reconciler-token-refresh.spec.md — Implementation Notes table (~line 143)

The exec-retry loop contains a sdk.Sessions().Get(checkCtx, sessionID) call inside the codes.NotFound branch that checks current phase before exiting the retry loop. This is a distinct call from the "completion marking" entry and is not listed in the table. Same consequence as above.

Add this call site to the table.


🟡 Major — failSession path: ForProject failure reproduces the original bug

File: specs/platform/reconciler-token-refresh.spec.md — Scenario: Token refresh fails (~line 124)

The "Token refresh fails" scenario says to "skip with a warning, not crash the reconciler." But failSession calls UpdateStatus(phase=Failed) — if ForProject() fails inside that closure, UpdateStatus is never invoked and the session stays stuck in Creating indefinitely. That's the exact same outcome as the original bug, just triggered by the fix path.

The spec needs to explicitly address what failSession does when ForProject() itself returns an error: retry? use a stale token as fallback? make an out-of-band K8s patch?


🟡 Major — Fix altitude: same bug class exists in other long-running functions

File: specs/platform/reconciler-token-refresh.spec.md — Requirement: SDK Client Refresh (~line 60)

Patching 4–5 call sites inside execAfterReady fixes one function. Every other long-running reconciler function that acquires an SDK client and then waits (e.g. deprovisionSessionSandbox at line ~1863, any future function) is subject to the same expiry window. The spec should acknowledge why per-call-site patching is preferred over a client-level fix (e.g. making sdkclient.Client accept a func() (string, error) token provider), or it will invite the same question in the implementation PR review.


🟠 Minor — Risk level is inverted in the completion UpdateStatus row

File: specs/platform/reconciler-token-refresh.spec.md — Implementation Notes table, Risk column (~line 147)

The Running-phase UpdateStatus at line 949 (fires after readiness wait) is marked "High" and the Completed-phase UpdateStatus at "1059+" (fires after readiness wait plus the full entrypoint execution — potentially many more minutes) is marked "Medium." Token staleness is a function of total elapsed time, so the completion-phase update is the highest-risk call and should carry the "High" label.


🟠 Minor — Incident timeline: token had only ~39s of validity at capture time

File: specs/platform/reconciler-token-refresh.spec.md — Incident timeline (~line 26)

The incident table shows the token acquired at 21:24:48 (expires 21:29:48) and ForProject() called at 21:29:09 — leaving only ~39 seconds of remaining validity at capture time. The scenario framing uses T₀ as both token-acquisition time and polling start, implying a full ~5-minute expiry window from exec start. The real failure mode is narrower: a nearly-expired token can produce a 401 after a very short (30–90 second) sandbox wait. Unit tests simulating 6-minute waits to trigger a 401 won't cover the actual edge case.

Either use the real timing in the scenario or add a "nearly-expired at capture" test variant.


— Amber, ACP codebase intelligence

bsquizz and others added 2 commits August 3, 2026 10:40
Sessions get stuck in Creating when the OIDC token expires during the
sandbox readiness polling loop. The SDK client captures a static token
at provision time but execAfterReady can block for minutes before
calling UpdateStatus. This spec defines the fix: pass the factory
instead of a captured client so tokens are refreshed lazily at each
API call site.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add missing call sites to Implementation Notes table: sdk.Sessions().Get()
  at ~line 1054 (exec-retry loop) and ~line 1086 (stop_on_run_finished re-read)
- Fix risk levels: completion-phase calls are High, not Medium (longest elapsed time)
- Fix incident timeline: token had ~39s remaining at capture, not full 5min TTL
- Add "nearly expired at capture" scenario matching actual incident failure mode
- Add explicit failSession/ForProject failure scenario with K8s annotation fallback
- Add design decision section: why per-call-site patching over client-level token provider

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@bsquizz
bsquizz force-pushed the spec/reconciler-token-refresh branch from 24e2c39 to 60d80a7 Compare August 3, 2026 14:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants