Skip to content

feat: @adcp/client 5.0 + PostgresStateStore for cross-machine sessions#2263

Merged
bokelley merged 4 commits into
mainfrom
bokelley/adcp-client-5
Apr 17, 2026
Merged

feat: @adcp/client 5.0 + PostgresStateStore for cross-machine sessions#2263
bokelley merged 4 commits into
mainfrom
bokelley/adcp-client-5

Conversation

@bokelley

@bokelley bokelley commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Bumps @adcp/client 4.30.1 → 5.0.0 and backs training-agent session state with the SDK's new PostgresStateStore. Solves cross-machine persistence: property lists / media buys / collection lists created on one Fly machine are now visible on the other.

This unblocks #2235, #2236, and #2237 end-to-end (my prior PR fixed the handler bugs; persistence was the remaining gap). Also applies the TaskResult discriminated-union breaking change from 5.0.

What changed

  • server/src/training-agent/state.ts rewritten: getSession(key) is now async and loads from adcp_state (JSONB). Per-request AsyncLocalStorage cache for multi-handler-call sharing. flushDirtySessions() writes only sessions whose serialized shape actually changed.
  • Dispatcher wraps handler execution in runWithSessionContext and flushes only when the handler returns successfully (via flushable flag in the dispatcher return value). Thrown exceptions skip the flush, discarding half-mutated state.
  • All 48 tool handlers became async with await getSession(...). Logic unchanged.
  • getAllSessions() cross-session fallback removed — was a no-op after the refactor and was masking legitimate MEDIA_BUY_NOT_FOUND responses.
  • sessionKeyFromArgs validates input: caps brand.domain at 253 chars / RFC-1035 charset and account_id at 128 chars / alphanumeric+._-. Lowercases domain so DNS casing doesn't fork sessions. Prevents unbounded primary-key bloat from caller-supplied values.
  • deserializeSession filters __proto__ / constructor / prototype map keys.
  • lastGetProductsContext.products dropped from persistence (deterministic from catalog); proposals still persist for the draft→finalize flow. SessionState.lastGetProductsContext.products is now properly Product[] | undefined.
  • 5 MB cap per serialized session — if exceeded, flushDirtySessions throws (no longer silently drops). Defense-in-depth on top of per-collection mutation limits.
  • Migration 408_adcp_state_store.sql via getAdcpStateMigration().
  • member-tools.ts: 4 TaskResultFailure sites simplified (5.0 discriminated union guarantees error: string).
  • Cross-machine persistence test added: serverA creates → serverB reads via the shared store.

Reviews

Ran code-reviewer, security-reviewer, and adtech-product-expert across two rounds; addressed Must Fix and Should Fix items:

Round 1:

  • Over-flushing on read-only accesses → diff-based dirty detection
  • Brand.domain unbounded → length + charset validation
  • __proto__ pollution vector → key filter
  • getAllSessions silent cross-session → removed
  • setStateStore production guard added
  • 5 MB size cap added (defense-in-depth)

Round 2:

  • lastAccessedAt defeated the diff check (every read wrote) → stableStringify drops it from the comparison
  • Snapshot compared raw stored shape vs. round-trip (caused first flush of every session to look dirty) → snapshot is now serializeSession(session) after load
  • Size cap silently dropped writes (handler returned 200 OK, data was gone) → flushDirtySessions throws on oversize
  • markHandlerFailed was fragile → dispatcher now returns { result, flushable } so the outer wrapper unambiguously decides; internal handler catches don't silently flush partial state
  • clearSessions duck-typed clearCollection → now uses instanceof PostgresStateStore
  • DNS case-sensitivity → session key lowercases domain

Known limitation documented: concurrent requests against the same session key use last-writer-wins (acceptable for the sequential-storyboard sandbox; production sellers should use per-entity collections via createAdcpServer).

Test plan

Out of scope

  • Full createAdcpServer domain-grouped migration — documented as a future refactor. This PR keeps the hand-rolled handler architecture and swaps only the persistence layer, which is the minimum change to unblock the storyboards.

🤖 Generated with Claude Code

bokelley and others added 2 commits April 17, 2026 08:17
Bump @adcp/client 4.30.1 → 5.0.0 and back training-agent session state
with the SDK's PostgresStateStore. Solves cross-machine persistence: the
training agent's in-memory Map didn't survive the 2 Fly.io machines, so
property lists / media buys / collection lists created on one machine
couldn't be read on the other.

- state.ts rewritten: async getSession(), AsyncLocalStorage per-request
  cache, serialize/deserialize SessionState to JSONB, flushDirtySessions()
  only writes sessions whose serialized shape actually changed.
- Dispatcher wraps handler execution in runWithSessionContext and flushes
  on clean return — thrown exceptions discard half-mutated state.
- All 48 tool handlers made async with await getSession(). Logic unchanged.
- getAllSessions() cross-session fallback removed (was a no-op after the
  refactor and masked legitimate MEDIA_BUY_NOT_FOUND cases).
- sessionKeyFromArgs caps brand.domain (253 chars, RFC-1035) and
  account_id (128 chars, alphanumeric+._-) — prevents unbounded adcp_state
  primary-key bloat from attacker-supplied values.
- deserializeSession filters __proto__/constructor/prototype keys.
- lastGetProductsContext.products dropped from persistence (deterministic
  from catalog); proposals still persist for the draft→finalize lifecycle.
- 5 MB cap per session payload to bound JSONB row size.
- Migration 397_adcp_state_store.sql via getAdcpStateMigration().
- TaskResult discriminated-union absorption in addie/mcp/member-tools.ts.
- Cross-machine test added: serverA create → serverB read via the store.

Tests: 298 training-agent + 587 tests/ suite, all passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
main has moved past 397 and 407; bump the new migration to 408.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Skip flushDirtySessions() when a handler threw mid-request. The
  dispatcher's catch block now calls markHandlerFailed() so the outer
  flush is a no-op; partial mutations are not persisted. Previously
  the finally-style flush ran even on thrown-error paths.

- Remove unsafe `products: undefined as unknown as Product[]` cast
  in deserializeSession. `lastGetProductsContext.products` is now
  `Product[] | undefined` in SessionState; every consumer already
  handles the absent case via fallback-to-catalog.

- Lowercase brand domain in session key derivation so Acme.com and
  acme.com share a session (DNS is case-insensitive). Prevents a
  user with mixed-case input from writing into a different session
  than a user with lowercase input for the same brand.

Tests:
- New: does not flush when handler failed mid-request (seeds state,
  triggers failed-mutation, verifies original state preserved).
- New: lowercases brand domain so DNS casing does not fork sessions.
- All 300 training-agent tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…h signal

Three correctness bugs in the flush/snapshot machinery:

1. lastAccessedAt defeated the diff check. getSession() updates
   lastAccessedAt on every load; serializeSession includes it; so
   the flush-time serialization always differed from the load-time
   snapshot. Every read wrote. Fix: stableStringify drops
   lastAccessedAt from the comparison.

2. Snapshot used raw storedShape, not round-trip. If deserialize
   then serialize normalized anything (optional keys, ISO precision),
   the first flush of every session looked dirty. Fix: snapshot
   serializeSession(session) after load, so the comparison is
   against a canonical form.

3. Size cap silently dropped writes. A handler that pushed the
   session past 5 MB returned 200 OK to the caller, and the flush
   logged a warning and moved on — the data was gone. Fix:
   flushDirtySessions throws on oversize. The MCP transport layer
   surfaces the failure; operators notice.

Plus a structural fix:

4. Dispatcher return signal replaces markHandlerFailed flag. The
   flag approach was fragile — a handler that caught internally
   could still flush partial state. Now dispatchCallTool returns
   { result, flushable } and the outer wrapper unambiguously
   decides. Thrown handlers set flushable=false; validation /
   structured-error paths set flushable=true.

5. clearSessions() no longer duck-types clearCollection — uses
   instanceof PostgresStateStore. Tests-only code; future stores
   outside the two known implementations are a no-op (documented).

6. sessionKeyFromArgs emits a debug log when brand.domain is
   rejected and collapses to open:default — makes malformed callers
   visible without changing behaviour.

Tests:
- read-only access does not flush (monkey-patches store.put,
  asserts zero writes after a pure-read request)
- snapshot matches round-trip serialization (same test setup,
  load without mutation, verify no flush)
- dispatcher skips flush when handler throws (via real MCP
  simulateCallTool path, not the removed markHandlerFailed helper)

302/302 training-agent tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

Training agent: property_list and collection_list in package targeting accepted but not persisted

1 participant