Skip to content

feat(serenity): async Semrush sub-workspace provisioning worker (LLMO-7352/LLMO-7418) - #3233

Open
IrisAlexandrescu wants to merge 9 commits into
fix/LLMO-7352-workspace-terminal-failurefrom
feat/LLMO-7418-provisioning-worker
Open

feat(serenity): async Semrush sub-workspace provisioning worker (LLMO-7352/LLMO-7418)#3233
IrisAlexandrescu wants to merge 9 commits into
fix/LLMO-7352-workspace-terminal-failurefrom
feat/LLMO-7418-provisioning-worker

Conversation

@IrisAlexandrescu

Copy link
Copy Markdown
Contributor

Summary

Adds an async, SQS-triggered worker (src/support/serenity/handlers/provision-workspace-job.js) that provisions a brand's Semrush sub-workspace out of the request path — the replacement for the synchronous create-then-poll flow that caused LLMO-7352: a brand could get silently, permanently bound to a workspace that never finished creating, breaking "Add Market" for that brand with no operator visibility until a customer complained.

  • One invocation does at most one create-or-adopt call and one status poll, then self-requeues via SQS DelaySeconds with bounded exponential backoff (5s/10s/20s/40s/80s, capped at 5 hops) instead of looping or sleeping in-Lambda.
  • Every write to the brand row (getBrandProvisioningState, persistProvisioningCandidate, updateProvisioningJobId, promoteProvisioningReady, promoteProvisioningFailed — new in brands-storage.js) is an attempt-id-scoped compare-and-set, so a stale/superseded attempt (a retry, or a late at-least-once SQS redelivery) can never clobber a newer winner's write.
  • createOrAdoptSubworkspaceCandidate is extracted from the existing ensureSubworkspace in workspace-lifecycle.js (byte-identical, ensureSubworkspace itself is untouched) so the worker reuses the exact same, already-tested claim-filter/504-recovery logic without a second, drifting copy.
  • Cleanup provenance (freshlyCreated) is threaded through the self-requeue job metadata rather than re-derived per invocation, so a superseded attempt's own real, freshly-created workspace is reliably cleaned up regardless of which hop discovers the supersession — an earlier version of this PR lost that provenance across hops; caught in adversarial review (see below).
  • Any unexpected exception (network blip, DB error) now best-effort records semrush_provisioning_status: 'failed' (sanitized message, never echoing raw upstream error text) before re-throwing, so a brand can no longer be stranded at pending forever with no job ever revisiting it.

Scope note: this PR ships the worker with no producer wired to it yet — no HTTP call site starts an attempt or enqueues serenity-provision-workspace today. That's PR-C (endpoint conversions to 202 + guards on the still-synchronous call sites).

Known, deliberately deferred gap: the synchronous ensureSubworkspace (Add Market, project/market activation) has no awareness of semrush_provisioning_status, so a concurrent sync call while an async attempt is mid-flight can independently create a second workspace for the same brand — a pre-existing race this PR widens from a single in-request duration to up to ~2.5 minutes. Documented explicitly in ensureSubworkspace's JSDoc; the fix (pending-status guards on those call sites) is PR-C.

Review

This PR went through a 4-agent review (adversarial, architect, senior-engineer, observability) against the real diff before opening. The adversarial pass found 3 real blockers, all fixed and covered by new tests before this PR was opened:

  • A non-exclusive CAS in persistProvisioningCandidate that could leak a workspace under two concurrent (at-least-once) SQS deliveries of the same first hop — fixed with an .is(..., null) guard.
  • Lost freshlyCreated cleanup provenance across self-requeue hops — fixed by threading it through job metadata instead of re-reading the DB.
  • Uncaught exceptions leaving the brand row stranded at pending forever — fixed with an outer catch that best-effort records failure before re-throwing.

The observability pass independently flagged the same uncaught-exception gap from the logging side (the outer job-runner's catch only logged the job ID, not brand/attempt context) — also fixed.

Test plan

  • npx mocha --require test/setup-env.js "test/support/serenity/**/*.test.js" "test/support/brands-storage.test.js" "test/serenity-prompt-classification/index.test.js" — 1805 passing.
  • Full suite: npm test — 18,135 passing, 0 failing (the coverage-threshold gate fails on pre-existing, unrelated 0%-covered files not touched by this PR).
  • npm run type-check — clean.
  • npx eslint on all touched files — clean.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

This PR will trigger a minor release when merged.

@codecov

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.89496% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/support/serenity/workspace-lifecycle.js 99.22% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Iris Alexandrescu and others added 7 commits September 10, 2026 10:33
…-7352/LLMO-7418)

Adds an SQS-triggered worker that provisions a brand's Semrush
sub-workspace out of the request path: one invocation does at most one
create-or-adopt call and one status poll, then self-requeues with bounded
exponential backoff (DelaySeconds, capped at 5 hops) instead of looping or
sleeping in-Lambda. Every write to the brand row is an attempt-id-scoped
compare-and-set, so a stale/superseded attempt (a retry, or a late
at-least-once SQS redelivery) can never clobber a newer one, and cleanup
provenance (freshlyCreated) is threaded through the self-requeue job
metadata rather than re-derived per invocation, so a superseded attempt's
own real workspace is reliably cleaned up regardless of which hop
discovers the supersession. This is the replacement for the synchronous
create-then-poll flow that could silently, permanently bind a brand to a
workspace that never finished creating.

Introduced by: N/A
…anchor, clean up orphans on any promotion failure (LLMO-7418 external-review Finding 5)

promoteProvisioningReady wrote status: 'active' unconditionally on ready
promotion, with no site_id check — bypassing the exact anchor invariant
upsertBrand already enforces at create time (a siteless brand is forced to
'pending', never 'active'). The async bare-create path never requires
baseSiteId, so a caller that omits it (the common case per prod data) hit
the live chk_active_brand_has_site_id CHECK the moment the worker tried to
promote — a 23514 not handled by the existing UNIQUE-conflict branch, so it
fell to the generic catch, which recorded the failure but never cleaned up
the Semrush workspace this same call had just confirmed ready, leaking it
permanently on every retry.

- getBrandProvisioningState now also reads site_id; the worker reuses this
  same state read (no extra query) to pass hasSiteAnchor into
  promoteProvisioningReady, which omits `status` from its update entirely
  when false rather than attempting (and violating) the CHECK.
- Hoisted the handler's `transport` construction above the try (it was
  declared twice, once inline in an early-return branch) so cleanup is
  reachable from every failure path that needs it.
- The ready-promotion's own try/catch now cleans up a freshly-created
  candidate for ANY promotion failure, not just the already-handled UNIQUE
  conflict — precisely scoped to this catch (not the outer generic one),
  since the self-requeue path deliberately carries its candidate forward and
  must never have it cleaned up mid-chain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…LLMO-7418 external-review Finding 3)

promoteProvisioningReady's CAS predicate (attempt_id + semrush_provisioning_status
= 'pending') never checks the brand's own lifecycle status column, so a legitimate
concurrent /serenity/deactivate was invisible to it: the attempt's later
ready-promotion would still match and silently flip status back to 'active',
resurrecting a brand the caller had just deliberately deactivated.

Rather than teach promoteProvisioningReady brand-lifecycle semantics it otherwise
has no reason to know, deactivate now cancels whatever attempt is currently
pending as part of its own (best-effort) write, after its primary decommission
work has already succeeded. This reuses the EXISTING, already-tested supersede
mechanism: provisionWorkspaceHandler's own currency re-check at the top of every
hop already stands down cleanly the moment semrush_provisioning_status is no
longer 'pending' — cancelProvisioningAttempt just needs to make that true.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… the write (LLMO-7418 external-review Finding 7, corrected)

The external review's original framing ("the column is written but never
read, remove it") was incomplete: persistProvisioningCandidate's CAS
`.is('semrush_provisioning_candidate_workspace_id', null)` check is a real
double-delivery mutex — two concurrent SQS deliveries of the same first hop
start with identical, empty job metadata, so only a DB-level compare-and-set
can tell them apart and let exactly one proceed. Dropping the column
entirely, as originally proposed, would have silently reintroduced that
race. Confirmed and corrected before touching anything.

The genuinely dead part is narrower: getBrandProvisioningState selected the
column back and surfaced it as `provisioningCandidateWorkspaceId`, which no
caller anywhere ever consulted (the worker's actual candidate resolution is
entirely metadata-based, threaded hop-to-hop through the self-requeue
payload). Removed that read-back only; the write, and its CAS guard, are
unchanged. Cleaned up the now-misleading test fixtures that set this field
on a mocked state as if it mattered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…MO-7418 external-review Finding 4)

Correction to the remediation plan's own earlier mislabeling: this was never
an OpenAPI documentation issue — the review's actual finding is that no
async enqueue site in controllers/serenity.js passed `title` in the
provision-workspace-job metadata, while provisionWorkspaceHandler forwards
it straight into createOrAdoptSubworkspaceCandidate with no validation. Only
brands.js's two createBrandForOrg sites passed it correctly.

Traced each of the 4 serenity.js call sites individually rather than fixing
all of them uniformly:
- createMarket's and activate's project-activation (batch-market) async
  branches are provably safe as-is: both are documented, code-referenced
  invariants (`auth.mode === 'subworkspace'` / an already-ACTIVE brand) that
  the brand already has a canonical workspace pointer, so the worker's
  existing-pointer fast path is always taken and `title` is never read.
  Adding it there would be unjustified defensive code for a case that
  cannot happen.
- activate's pending->active branch operates on a brand that is GUARANTEED
  pointer-less (a pending brand never has one), so the worker ALWAYS takes
  the create-or-adopt path — this was a real, always-triggered bug that
  called Semrush with an untitled sub-workspace on every async activation of
  a pending brand. Fixed: passes `title: brand.getName()`.
- activate's bare-reactivation branch mirrors its own synchronous twin,
  which defensively calls the general-purpose `ensureSubworkspace`
  (create-or-existing) rather than assuming a pointer always exists — the
  async path needed the same defensiveness. Fixed identically.

Also adds the worker's own last line of defense (this repo, provision-
workspace-job.js): fail loudly with a clear error if `metadata.title` is
ever missing when about to create, rather than silently asking Semrush to
create an unrecoverable, permanently-unadoptable untitled sub-workspace.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… up its candidate (LLMO-7418 external-review Blocker 2)

The currency check conflated two distinct conditions under one branch: "a
DIFFERENT attempt now owns the brand" (genuine supersession — our own
candidate, if any, is a stale leftover safe to clean up) and "OUR OWN
attempt already reached a terminal status" (ready or failed — NOT a
supersession, just an at-least-once SQS redelivery of a message whose
earlier delivery already finished this exact job).

Proven data-loss path: once a hop's candidate is promoted to ready
(semrush_provisioning_status flips to 'ready', attempt_id unchanged) but
before this Lambda invocation's own job.save() marks the AsyncJob
COMPLETED, a redelivery of the SAME message can re-enter this handler.
Under the old check, `state.provisioningStatus !== 'pending'` alone was
enough to trigger cleanup — and by then `candidate` (carried forward via
job metadata from a self-requeue hop) is the brand's now-CANONICAL, LIVE
workspace, which may already hold a real market project the chained job
created. cleanupIfOwned would empty it, deleting live customer data on a
concurrent/duplicate delivery.

Fix: only clean up when the attempt id itself differs. When the attempt id
matches but the status is no longer pending, stand down as a true no-op —
never touch the candidate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ifier regressions in tests (LLMO-7418 external-review Finding 16 + Medium)

Finding 16: orphan cleanup was narrower than the code's own comments claimed.
Two gaps closed:
- The requeue-exhausted branch (MAX_PROVISION_REQUEUE_DEPTH reached) recorded
  the attempt failed but never cleaned up a freshly-created candidate — normally
  a no-op (a not-ready shell has no projects yet) but closes the rarer case
  where one exists despite the not-ready status, and nothing else will ever
  revisit this attempt once it's failed.
- The generic outer catch still had no cleanup call at all. A blanket fix would
  have been unsafe: once a self-requeue's own createAndEnqueueJob call succeeds,
  a FUTURE hop already owns the candidate and will poll it — cleaning it up in
  the outer catch (e.g. because the subsequent freshness-optimization write
  throws) would corrupt the workspace the next invocation is about to use.
  Added a `requeueEnqueued` flag, set only once that enqueue call itself
  succeeds, to gate the outer catch's cleanup precisely. Also tracks whether
  promoteProvisioningReady's own inner catch already cleaned up before
  rethrowing, to avoid a harmless but noisy double cleanupIfOwned call.

Medium (test stub divergence): provision-workspace-job.test.js hand-rolled
stubs for isWorkspaceReady/isWorkspaceTerminalFailure that omitted the
space-separated 'creation failed' variant (the one actually observed in
production) and dropped all case/whitespace normalization — pure functions
with no reason to be re-implemented in a test double, and a real regression
in either the classifiers or this handler's own use of them could hide
behind the stub indefinitely. Removed the override; tests now exercise the
real implementations (esmock passes them through un-mocked).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…O-7418 external-review N9, N3, Finding 3)

- N9: a transient failure of the best-effort job-id refresh AFTER a successful
  self-requeue no longer reaches the outer catch. Previously it marked the still-live
  attempt `failed` and re-threw, invalidating the promise token the requeued hop needs
  -- killing a healthy attempt and orphaning its workspace. The write is wrapped locally,
  matching its own "optimization, not safety mechanism" contract.
- N3: createSerenityTransport is now constructed INSIDE the try. normalizeBaseUrl throws
  a 503 on a missing/malformed SEMRUSH_PROJECTS_BASE_URL; building it before the try let
  that escape failBestEffort and strand the brand at `pending` forever. The catch guards
  its only transport use on `transport &&` since construction can now fail.
- Finding 3 (delete/deactivate resurrection): promoteProvisioningReady's CAS now gates on
  `status IN ('pending','active')`, so a soft-deleted ('deleted') or offboarded ('ignored')
  brand -- neither of which touches the provisioning columns -- can no longer be flipped
  back to `active` with a fresh workspace by a late worker hop.

Tests: added coverage for all three (post-requeue freshness-write swallow, transport
construction failure recorded-not-stranded, CAS status predicate + deleted-brand lost race).
Suites: provision-workspace-job + brands-storage = 293 passing. eslint + type-check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…418 external-review Finding 7)

The docstring claimed the worker uses this read "to decide whether to resume an
already-persisted candidate (skip create-or-adopt) or run it for the first time" --
contradicting the PROVISIONING_SELECT comment three lines above it, which explains that the
candidate column is deliberately NOT selected and that candidate resolution is entirely
metadata-based (threaded hop-to-hop through the self-requeue payload).

Corrected to state what the read is actually for (attempt-id currency + still-pending, the
existing-pointer fast path, and the ready-promotion's site anchor) and to record explicitly
that it does NOT resolve the in-flight candidate.

Comment-only; no behavior change.

Co-Authored-By: Claude Opus 5 (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.

1 participant