Skip to content

fix(agent-sessions): quota refusal delivery + storage-measurement generation CAS - #2253

Closed
2witstudios wants to merge 3 commits into
masterfrom
pu/agent-sessions-followup
Closed

2witstudios wants to merge 3 commits into
masterfrom
pu/agent-sessions-followup

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 29, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to #2249, which merged at 14:47 UTC today while a review pass was still running. These fixes were verified after the merge, so they never landed with it. All are on code #2249 introduced.

Two of the three are P1s reported by Codex on post-merge re-reviews — the second confirmed and reproduced against real Postgres, the third a defect Codex found in my fix for the second.


1. The plan-limit refusal reached the user as an enum code

sessionQuotaExceeded answered { error: detail ?? SESSION_QUOTA_MESSAGE }. detail is quota.reason — 'concurrency_limit', and always set — so the ?? never fell through: both human strings on that path were unreachable and a user over their sandbox limit received

{"error":"concurrency_limit"}

rendered straight into the product, while the socket surface printed the real sentence for the identical refusal. That is the "one event should not read three different ways depending on which layer said no" the module was extracted to prevent.

Compounding it, handleAddShell was try/finally with no catch, so the 429 rejected out of an onClick as an unhandled rejection — spinner stopped, no tab, nothing said. A limit the product enforces but never states is indistinguishable from a bug.

  • detail is now what it always was: a diagnostic code, recorded in the audit row, never in the body.
  • The provisioner's dead prose fallback becomes a code, so no caller can read detail as user copy again.
  • Both shell handlers surface failures via the toast convention already used in this directory.

The copy had a test; the path to it did not. Added: the body is the human sentence whatever code the provisioner names, the code still reaches the audit trail, the button re-enables after failure, and both handlers report rather than swallow. Mutation-verified — restoring detail ?? fails two of them.

2. [P1] Storage measurements could bill the next Sprite generation for the previous one's disk

recordStorageMeasurement wrote on (conversationId, spriteTornDownAt IS NULL) — no generation guard, in a store where every other write CASes.

The measurement is fire-and-forget, so a du against generation A can still be running when A is torn down and B provisioned. B's own stamps clear spriteTornDownAt and null the measurement columns (plan-session-lifecycle.ts:218), so A's late write finds a live, freshly-reset row: it lands A's bytes on B and stamps a fresh storageMeasuredAt that suppresses B's real measurement for the whole throttle window while the reconcile bills B's interval against A's disk.

The guard has to be spriteInstanceId, not sandboxId — the name is HMAC-derived from the session and is identical across generations, so a CAS on it would have looked like a fix and caught nothing. SandboxHandle already states the rule: "anything that must act on THIS VM (a kill, a CAS against a tracking row) keys on this."

The interface comment claimed this protection ("would bill the next generation against a dead disk") while the predicate delivered it only for the never-revived case.

  • CAS on spriteInstanceId, via eqOrIsNull so a driver reporting no instance id still matches its own null row.
  • The instance travels with the bytes through the pure measure module to all four call sites (web create + warm, realtime create + resume). Making the field required is what surfaced them — two realtime callers were destructuring {measuredBytes, measuredAt} and would have dropped it silently.
  • The in-memory fake honours the CAS: a fake that accepts writes the real store rejects makes every test using it agree with the bug.

3. The CAS was reading its generation from the wrong place

Codex, reviewing commit 2 — and correct. A CAS is only as good as the id handed to it, and I handed it the row's.

measureWarmSessionStorage read spriteInstanceId off the session row. But this path is fed a sandbox the tool run already acquired, and the measurement is fire-and-forget — so between capturing that handle and reading the row, the session can be torn down and re-provisioned. The row then names generation B while du walks A's disk, and the CAS succeeds against B carrying A's bytes and A's throttle timestamp: the exact write the CAS was added to reject, waved through by its own guard.

I had considered this window in commit 2 and called it a residual. It isn't residual on this path — the handle is captured before the row read, so the mismatch is the normal ordering, not a rare interleaving. A guard that is wrong in the common case is worse than none, because it reads as covered.

ExecutableSandbox extends SandboxHandle, so the acquired sandbox already knows its own generation — no contract widening, just carrying what was there:

  • attach() returns { exec, spriteInstanceId }; the CAS uses the handle's id — the identity of the disk actually measured.
  • The row is still read, but only for the throttle, which is all it can honestly say about a handle it did not produce.
  • Same correction on the realtime resume path.

All five measurement call sites now source the id from a handle; none from a row. Create-arm ordering re-checked: updateSpriteIdentity is confirmed (recorded === true) before measurement fires, so tightening the predicate does not drop the baseline measurement.

Validation

  • typecheck, lint clean across the monorepo.
  • packages/lib agent-sessions + sandbox: 928 tests. apps/realtime: 937. apps/web agent-sessions + agents + tools: 1530 passing (1 unrelated pre-existing failure, activity-tools, which needs a test DB and fails on Failed query in any local env).
  • Integration suite run against real Postgres (throwaway instance, all 235 migrations applied): 19 tests, including three new CAS cases — stale generation dropped (bytes and timestamp), same generation persists despite a reused NAME, null-instance driver still persists.
  • Mutation-verified, both fixes: restoring detail ?? fails two quota tests; removing the CAS predicate fails the stale-generation case; pinning the supplied instance id to null fails the seam test. Each fails that test and nothing else.

The timestamp assertion is the one worth keeping: a stale storageMeasuredAt silences the next real measurement for a full window without ever recording a real number, so dropping only the bytes would leave half the bug.

Review coverage

Stated explicitly because #2249 taught the lesson: a green bot check is not evidence of review.

  • Codex has reviewed HEAD (9a0d8f634) — no issues. Its two findings on earlier commits are fixed and left open for verification.
  • CodeRabbit reviewed commits 1–2 with no actionable comments, then reviewed commit 3 on request — "Review finished", no findings.

Still open from the Codex re-review (not in this PR)

Two findings against merged code, now filed so they outlive this PR:

  1. [P1] → Reclaim outbox can keep a stale spriteInstanceId, orphaning a live Sprite #2254 0234_phase8_teardown_machines_world.sql — the four rescue INSERTs use ON CONFLICT DO NOTHING, while the 0209/0219/0229 triggers use DO UPDATE SET spriteInstanceId = COALESCE(...) precisely because "a newer generation took this name; the pointer must chase the VM that is actually alive now." If the outbox already held that sandboxId from an older generation, the rescue keeps the stale instance id while the migration drops the current generation's tracking row; agent-session-orphan-reconcile-runtime.ts:132 then returns {ok: true} on SandboxSpriteReplacedError, deleting the outbox row and orphaning a live Sprite. This migration is already on master, so the fix is not an edit to it — needs a decision between a corrective migration and hardening the reconciler's replaced-instance handling for outbox rows, which is where the host's own comment says the safe behaviour is to keep the pointer.
  2. [P2] → Agent-created shells never appear as tabs (no invalidation for spawn_shell/kill_shell) #2255 useSessionShells.ts — agent-invoked spawn_shell/kill_shell mutate shell rows without touching this SWR cache, which has no polling or socket invalidation and disables focus revalidation, so agent-created shells never appear as tabs.

🤖 Generated with Claude Code

https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro

Summary by CodeRabbit

  • Bug Fixes

    • Improved storage measurement accuracy by preventing stale data from older sandbox instances from overwriting current session data.
    • Added clearer, user-friendly quota error messages while preserving diagnostic details for internal auditing.
    • Added error notifications when adding or closing agent shells fails.
    • Ensured the “Add shell” action becomes available again after an error.
  • Tests

    • Expanded coverage for quota messaging, shell operation errors, and storage measurement safeguards.

2witstudios and others added 2 commits July 29, 2026 10:20
… an enum code

The plan-limit refusal had a carefully-worded message that no user could
ever see, and a delivery path that showed nothing at all.

1. `{ error: detail ?? SESSION_QUOTA_MESSAGE }` never fell through.
   `detail` is `quota.reason` — `'concurrency_limit'`, always set — so
   both human strings on this path were unreachable and a user over
   their limit received `{"error":"concurrency_limit"}`. The socket
   surface printed the real sentence for the identical refusal: the
   three-ways-to-say-one-thing the module was extracted to prevent.
   `detail` is now what it always was, a diagnostic code, recorded in
   the audit row and never in the body.

2. The provisioner's dead prose fallback becomes a code, so no caller
   can read `detail` as user copy again.

3. `handleAddShell` was try/finally with no catch, so the 429 rejected
   out of an onClick as an unhandled rejection: spinner stopped, no tab,
   no explanation. A limit the product enforces but never states is
   indistinguishable from a bug. Both shell handlers now surface the
   failure via the toast convention already used in this directory.

The copy had a test; the path to it did not. Added: the response body
is the human sentence whatever code the provisioner names, the code
still reaches the audit trail, the button re-enables after a failure,
and both shell handlers report rather than swallow. Verified by
mutation — restoring `detail ??` fails two of them.

Found by re-reading the files no automated reviewer has seen: Codex
reviewed 36 commits ago and CodeRabbit skipped this PR entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
…ration

Reported by Codex on the re-review. Confirmed, reproduced against real
Postgres, and fixed.

`recordStorageMeasurement` wrote on `(conversationId, spriteTornDownAt
IS NULL)` — no generation guard, in a store where every other write
CASes. The measurement is fire-and-forget, so a `du` against generation
A can still be running when A is torn down and B provisioned. B's own
stamps clear `spriteTornDownAt` and null the measurement columns
(plan-session-lifecycle.ts:218), so A's late write finds a live row,
lands A's bytes on B, and stamps a fresh `storageMeasuredAt` that
suppresses B's real measurement for the whole throttle window while the
reconcile bills B's interval against A's disk.

The guard has to be `spriteInstanceId`, not `sandboxId`: the name is
HMAC-derived from the session and is identical across generations.
`SandboxHandle` already says so — "anything that must act on THIS VM
(a kill, a CAS against a tracking row) keys on this".

The interface comment claimed this protection ("would bill the next
generation against a dead disk") while the predicate delivered it only
for the never-revived case.

- store: CAS on `spriteInstanceId`, `eqOrIsNull` so a driver reporting
  no instance id still matches its own null row.
- the instance travels with the bytes through the pure measure module
  to all four call sites (web create + warm, realtime create + resume);
  the required field is what surfaced them.
- the in-memory fake honours the CAS — a fake that accepts writes the
  real store rejects makes every test using it agree with the bug.

Tests: three integration cases on real Postgres — stale generation
dropped (bytes AND timestamp), same generation persists despite a reused
NAME, null-instance driver still persists. Verified by mutation:
removing the predicate fails the stale-generation case and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
@coderabbitai

coderabbitai Bot commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Storage measurements now carry Sprite instance identifiers and use generation-scoped persistence guards. Quota responses separate public messages from audit diagnostics, and agent shell failures now produce toast feedback with tested loading-state recovery.

Changes

Generation-scoped storage measurements

Layer / File(s) Summary
Measurement identity propagation
packages/lib/src/services/sandbox/sandbox-storage-measure.ts, apps/web/src/lib/agent-sessions/agent-sessions-runtime.ts, apps/realtime/src/index.ts, packages/lib/src/services/sandbox/__tests__/sandbox-storage-measure.test.ts
Storage measurement contracts and runtime paths now pass nullable spriteInstanceId values through persistence callbacks.
Generation-scoped persistence
packages/lib/src/services/agent-sessions/agent-sessions-store.ts, packages/lib/src/services/agent-sessions/__tests__/fakes.ts, packages/lib/src/services/agent-sessions/__tests__/agent-sessions-store.integration.test.ts
Database and fake stores require a live row with a matching Sprite instance identifier, with integration coverage for stale, same-generation, and null-identifier writes.

Quota error response handling

Layer / File(s) Summary
Stable quota denial responses
packages/lib/src/services/agent-sessions/agent-session-sprite.ts, apps/web/src/lib/agent-sessions/quota-response.ts, apps/web/src/lib/agent-sessions/__tests__/quota-response.test.ts, packages/lib/src/services/agent-sessions/__tests__/agent-session-sprite.test.ts
Quota denials use internal reason codes, return the shared HTTP 429 message, and preserve raw details in audit records.

Shell operation error feedback

Layer / File(s) Summary
Shell mutation failure handling
apps/web/src/components/agents/AgentView.tsx, apps/web/src/components/agents/__tests__/AgentView.test.tsx
Shell add and remove failures are caught, logged, and displayed through toast errors; tests cover specific messages, generic failures, and button recovery.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AgentView
  participant ShellSession
  participant SonnerToast
  AgentView->>ShellSession: addShell()
  ShellSession-->>AgentView: error
  AgentView->>SonnerToast: error title and description
  AgentView->>AgentView: reset isAddingShell
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the two main changes: quota refusal delivery and storage-measurement generation CAS.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/agent-sessions-followup

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 82aacbfa8e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// If a teardown + re-provision lands while the walk runs, the row's
// instance moves on and the write is dropped rather than billing the new
// Sprite for the old one's disk.
spriteInstanceId: row.spriteInstanceId ?? null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use the measured sandbox's instance for the CAS

When a tool finishes on generation A while that session is torn down and reprovisioned as B before this function reads the row, row.spriteInstanceId is B even though input.attach() still returns the already-acquired sandbox A (see sandbox-tools-runtime.ts, where the closure executes on the captured sandbox). The resulting du reads A but persists with B's instance ID, so the new CAS succeeds against B and still records A's bytes and throttle timestamp on the replacement generation. Carry the captured sandbox's spriteInstanceId through attach and CAS using that measured handle identity instead.

Useful? React with 👍 / 👎.

… row's

Codex, reviewing the previous commit: the CAS was only as good as the id
handed to it, and I handed it the wrong one.

`measureWarmSessionStorage` read `spriteInstanceId` off the session row.
But this path is fed a sandbox the tool run ALREADY acquired, and the
measurement is fire-and-forget — so between acquiring the handle and
reading the row, the session can be torn down and re-provisioned. The row
then says generation B while `du` walks A's disk, and the CAS "succeeds"
against B carrying A's bytes and A's throttle timestamp: precisely the
write the CAS was added to reject, waved through by its own guard.

`ExecutableSandbox extends SandboxHandle`, so the acquired sandbox knows
its own generation. `attach()` now returns it alongside `exec`, and the
CAS uses that — the identity of the disk actually measured. The row is
still read, but only for the throttle, which is all it can honestly say.

Same correction on the realtime resume path: CAS on the fetched
sandbox's instance, not the row's. There the two usually agree, since
the sandbox is fetched right after the row — but "usually" is what a CAS
is for.

Test: the seam supplies the ACQUIRED sandbox's instance through `attach`.
It was previously mocked wholesale, so nothing asserted what the seam
passed — which is where this hid. Mutation-verified: pinning the
supplied id to null fails it and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V3eqwRX5cFy3Tdu2Syoro
@2witstudios

Copy link
Copy Markdown
Owner Author

Re: [P1] Use the measured sandbox's instance for the CAS — correct, fixed in 9a0d8f634

This one lands squarely. The CAS I added in the previous commit was only as good as the id handed to it, and I handed it the wrong one.

I read spriteInstanceId off the session row. But this path is fed a sandbox the tool run already acquired — the closure in sandbox-tools-runtime.ts executes on the captured sandbox — and the measurement is fire-and-forget. So between acquiring that handle and reading the row, the session can be torn down and re-provisioned: the row says generation B while du walks A's disk, and the CAS succeeds against B carrying A's bytes and A's throttle timestamp. That is exactly the write the CAS was added to reject, waved through by its own guard.

Worth naming precisely, because I did consider this window and reasoned past it. My note in the first commit said the read-then-CAS pattern "doesn't prove the handle was that generation" and called it a residual. It isn't residual on this path — the handle is captured before the row read, so the mismatch is the normal ordering, not a rare interleaving. A guard that is wrong in the common case is worse than no guard, because it reads as covered.

Fix. ExecutableSandbox extends SandboxHandle, so the acquired sandbox already knows its own generation — no contract widening needed, just carrying what was there:

  • attach() returns { exec, spriteInstanceId }, and the CAS uses the handle's id: the identity of the disk actually measured.
  • The row is still read, but only for the throttle — which is all it can honestly say about a handle it did not produce.
  • Same correction on the realtime resume path. There the two usually agree, since the sandbox is fetched immediately after the row — but "usually" is what a CAS is for.

Test. measureWarmSessionStorage was mocked wholesale in the tools-runtime suite, so nothing asserted what the seam actually passed — which is where this hid, and it is the same shape as the original bug (the write had a guard; the guard's input had no test). The new test drives deps.measureStorage with a sandbox on instance-A and asserts attach() reports instance-A. Mutation-verified: pinning the supplied id to null fails it and nothing else.

Left open for your verification.


CodeRabbit reviewed this PR and returned no actionable comments — worth noting only because on #2249 it never ran at all (521 files against its 100-file limit) while reporting a green check. At 13 files it actually reviews.

@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: 9a0d8f634e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@2witstudios

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@2witstudios

Copy link
Copy Markdown
Owner Author

Point-guard triage (post-#2258 audit swarm): this branch predates the restore merge and edits files #2258 deleted (AgentView.tsx, old test suites), so it can't land as-is — but all three fixes are verified still missing from master (quota-response.ts:50 still leaks the enum; sandbox-storage-measure.ts still has no generation CAS). Re-landing plan:

Recommend closing this PR once the re-landed equivalents merge; keeping it open until then as the reference for the mutation-verified test matrix.

2witstudios added a commit that referenced this pull request Jul 30, 2026
…ing, sidebar polish

Fixes the follow-up audit findings from PR #2258 (issue #2264), plus a
live P1 the point guard surfaced from stale PR #2253.

- useResolvedConversation: gate first resolution on authLoading === false.
  On a hard refresh, useAuth starts out loading, so canUseSessions reads
  false before the role is known — resolving anyway would mint a fresh
  agent's first conversation as permanently session-less (thread→session
  binding is congenital). A startedForAgentId ref ensures this only waits
  out the FIRST load, so a later auth-loading blip (token refresh) never
  restarts resolution and clobbers a conversation the user is in.

- Split spawn refusals into quota (429, worth interrupting the user for —
  they have the capability and simply ran out of allowance) vs capability
  (everything else — the existing silent degrade to a plain conversation).
  New spawn-refusal.ts pure module + ApiRequestError (auth-fetch.ts) to
  carry the status code through the throw.

- quota-response.ts P1: `{ error: detail ?? SESSION_QUOTA_MESSAGE }` used
  `detail` for both a diagnostic enum (quota.reason, e.g.
  'concurrency_limit', ALWAYS set on denial) and an occasional human
  override — so the enum always won and users saw 'concurrency_limit'
  rendered as their error message, with the human sentence unreachable.
  Split into {reasonCode, message}: reasonCode goes only into the audit
  row, the response body is always human-worded. Addresses the
  quota-delivery half of PR #2253. Verified the new UI's 429 consumers
  (AgentPanes.tsx handlePickShell, AgentsSidebar.tsx spawn/newConversation/
  endSession) already catch and toast the rejection — no repeat of the
  deleted AgentView.tsx handleAddShell's silent-spinner bug.

- usePermissionsCheck: reset isReadOnly at effect start so a page change
  doesn't inherit the previous page's read-only verdict when its own check
  errors.

- AgentsSidebar: extracted session-groups.ts (groupSessionsByDrive) — the
  Assistant group now sorts first deterministically instead of depending
  on Map insertion order during the sessions fetch. Status dot gets
  role="img" so it's announced by screen readers (a bare aria-label span
  is not).

Coordination note: useResolvedConversation now takes an authLoading param;
AgentPageView.tsx (owned by the pane-system agent, issue #2263) got the
smallest possible touch — destructuring isLoading from useAuth and passing
it through — per the task's stated seam.

Closes #2264

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WZ55BHXrze46tyZDA9eNhU
@2witstudios

Copy link
Copy Markdown
Owner Author

Superseded in full: the quota-delivery fix re-landed in #2270, and the generation-CAS + handle-sourced instance id re-landed in #2274 (adapted to the post-#2258 session-keyed model, same mutation-verified test matrix). #2254 fixed by #2274; #2255 was closed earlier as superseded by the pane-system restore (#2268 added shell reattach). Nothing from this branch remains unlanded.

@2witstudios
2witstudios deleted the pu/agent-sessions-followup branch August 14, 2026 13:33
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.

1 participant