Skip to content

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

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

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

Conversation

@IrisAlexandrescu

@IrisAlexandrescu IrisAlexandrescu commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Second in the stack (base: #3223). Adds the SQS worker that provisions a Semrush sub-workspace off the request path — but wires up no producer, so nothing enqueues to it yet. Inert on merge by construction.

One create-or-adopt and one status poll per invocation, then a self-requeue with bounded backoff (5 hops), rather than blocking in-Lambda. The canonical pointer is written only after Semrush confirms the workspace is created — that is the actual fix for LLMO-7352.

Changes

  • New provision-workspace-job handler, registered in the serenity job runner.
  • Provisioning state on brands (status / attempt id / job id / error / candidate). Every write is an attempt-id-scoped compare-and-set, so a stale or superseded hop can never clobber a newer winner.
  • An accepted-but-unconfirmed workspace id is stored as diagnostic candidate state only, never as the canonical pointer.
  • Orphan cleanup on every promotion-failure path, and a redelivery of an already-terminal hop must not clean up its candidate.
  • deactivate cancels an in-flight provisioning attempt.

Notes for review

  • Idempotent under at-least-once SQS delivery: the worker re-reads its attempt id at the start of every invocation and exits if it is no longer current.
  • It does not sleep for the lifetime of a Lambda invocation — backoff is per-message delivery delay.
  • Two behavioural contracts from main now apply to these jobs and are exercised here: the typed promise-token contract, and the runner's message-type-vs-stored-type guard.

Test plan

  • npm run lint, npm run type-check, npm run docs:lint — clean
  • 363 passing across the worker entry, brands-storage, async-job-runner and the new handler
  • CI green (12 checks)
  • Rebased onto current main

Related Issues

🤖 Generated with Claude Code

@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.89919% with 1 line in your changes missing coverage. Please review.

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

📢 Thoughts on this report? Let us know!

@IrisAlexandrescu
IrisAlexandrescu force-pushed the fix/LLMO-7352-workspace-terminal-failure branch from f3d28a1 to f40444a Compare September 9, 2026 15:45
@IrisAlexandrescu
IrisAlexandrescu force-pushed the feat/LLMO-7418-provisioning-worker branch from 70fe12d to ae4b0a9 Compare September 9, 2026 16:00
@IrisAlexandrescu
IrisAlexandrescu force-pushed the fix/LLMO-7352-workspace-terminal-failure branch from f40444a to db0574f Compare September 10, 2026 07:33
@IrisAlexandrescu
IrisAlexandrescu force-pushed the feat/LLMO-7418-provisioning-worker branch from fce9e83 to 09c741f Compare September 10, 2026 07:42
@IrisAlexandrescu
IrisAlexandrescu force-pushed the fix/LLMO-7352-workspace-terminal-failure branch from db0574f to fcac3de Compare September 14, 2026 13:43
@IrisAlexandrescu
IrisAlexandrescu force-pushed the feat/LLMO-7418-provisioning-worker branch from 6ff60d2 to 146216b Compare September 14, 2026 13:43

@luis6156 luis6156 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Summary

The worker design is solid — the CAS-everywhere discipline is rigorous, the ownership threading for cleanup is carefully handled, and the "inert on merge" framing is the right way to land a complex piece without breaking prod. Three issues worth addressing before the producer is wired up, and one open process risk.


🔴 Misleading "redelivery" log when a cancelled brand's worker stands down

cancelProvisioningAttempt writes semrush_provisioning_status = 'failed' on the brand row. When the in-flight worker's NEXT delivery reads that state, it hits this branch:

if (state.provisioningStatus !== 'pending') {
  log?.info?.('provision-workspace-job: this attempt already reached a terminal state (redelivery); standing down without cleanup', {
    brandId, attemptId, currentStatus: state.provisioningStatus,
  });
  return { provisioningStatus: 'superseded' };
}

The label "redelivery" is wrong here — the status is 'failed' because the brand was deactivated, not because an earlier delivery of this same message already finished the job. An operator investigating why a brand's provisioning appears stuck would read "redelivery" and conclude this is a harmless SQS duplicate, when the real cause is a deliberate deactivation-cancel. The two cases need different log paths, or at minimum the log should include currentStatus in its message rather than implying a structural reason:

log?.info?.('provision-workspace-job: this attempt already reached a terminal state; standing down without cleanup', {
  brandId, attemptId, currentStatus: state.provisioningStatus,
  // 'failed' here means the brand was deactivated/cancelled — not necessarily a prior delivery
});

🟡 promoteProvisioningFailed CAS return value ignored in the terminal-failure branch

if (isWorkspaceTerminalFailure(status)) {
  await promoteProvisioningFailed({
    brandId, attemptId, error: TERMINAL_FAILURE_MESSAGE, postgrestClient,
  });
  log?.error?.('provision-workspace-job: sub-workspace settled to a terminal failure status', {
    brandId, attemptId, semrushWorkspaceId: candidate.workspaceId, status,
  });
  return { provisioningStatus: 'failed' };
}

If promoteProvisioningFailed returns false (CAS rejected because a newer attempt superseded this one), we still log an ERROR and return 'failed'. The behavior is safe — no cleanup is needed for a terminally-failed shell, and the newer attempt owns the brand — but the log's error-level emission and 'failed' return are misleading: we genuinely observed a terminal upstream failure, but the write didn't land. Checking the return and distinguishing this from a supersession would tighten both the logs and the return contract:

const recorded = await promoteProvisioningFailed({ ... });
if (!recorded) {
  log?.info?.('provision-workspace-job: terminal status observed but attempt already superseded', { ... });
  return { provisioningStatus: 'superseded' };
}
log?.error?.('provision-workspace-job: sub-workspace settled to a terminal failure status', { ... });
return { provisioningStatus: 'failed' };

🟡 504 recovery condition in createOrAdoptSubworkspaceCandidate — verify it matches ensureSubworkspace

} catch (e) {
  if (!(e instanceof ErrorWithStatusCode) && e?.status === 504) {
    created = await adoptFromFamily(transport, parentWorkspaceId, title, log, claim);
  } else {
    throw e;
  }
}

The condition enters the adoptFromFamily recovery branch only for errors that are not ErrorWithStatusCode instances. If transport.createSubworkspace throws an ErrorWithStatusCode(504) on a gateway timeout, this branch is dead code — the error is rethrown and 504 recovery silently breaks. Since this logic is extracted from ensureSubworkspace, the condition should match exactly what ensureSubworkspace uses. Please confirm the two are identical, especially that the transport actually emits a non-ErrorWithStatusCode error for this status (otherwise any test that mocks the transport won't catch the divergence either).


🟡 "PR-C" guard on ensureSubworkspace race has no enforcement mechanism

The comment added to ensureSubworkspace's JSDoc is correct and helpful:

Tracked as a required guard on this function's synchronous callers before general rollout (PR-C) — this comment documents the gap, it is not the fix.

But nothing in the codebase prevents the producer PR from merging before PR-C. Once the producer lands, two concurrent callers — a sync ensureSubworkspace and an async worker hop — can race on the same brand during the 0–155s provisioning window and create a duplicate workspace. Given that "the async worker widens the race window from a single in-request duration to that much longer span" (your own note), this is worth a test (FIXME or a failing integration test gated on a flag) or a CI comment rule in the producer PR to make the ordering explicit rather than relying on process memory.


What's good ✅

  • CAS design is rigorous end-to-end. Every write uses attempt_id + provisioningStatus = 'pending' scoped compare-and-set; promoteProvisioningReady additionally gates on status IN ('pending', 'active') to prevent resurrecting deleted/ignored brands. The persistProvisioningCandidate null-guard (IS NULL) closing the double-delivery duplicate-create race is particularly careful.
  • Ownership threading is correct. freshlyCreated threaded through self-requeue metadata (never re-derived from DB) is the right approach — the DB row may belong to a newer attempt by the time a superseded hop reads it. The outer-catch's requeueEnqueued + candidateAlreadyCleanedUp flags close all the cleanup edge cases without double-deletion.
  • failBestEffort in the outer catch prevents permanently-pending brands. The "strand at pending forever" failure mode it prevents was the core silent-failure that motivated the redesign.
  • "Inert on merge" construction. Registering the handler without a producer is the right deployment strategy for this complexity.
  • Token redaction from error logs. Deleting promiseToken/promisePair from the safe-metadata before logging is the right call; this fixes a real leakage path.
  • isWorkspaceReady/isWorkspaceTerminalFailure passed through un-mocked in tests. The comment in the test file explains exactly why — the hand-rolled stubs would have hidden the space-separated 'creation failed' variant. Good discipline.
  • Test coverage hits all the adversarial cases. CAS-loss, UNIQUE-conflict-409, post-requeue freshness-write failure (Finding N9), transport-construction failure (Finding N3), double-cleanup guard — all exercised.

Iris Alexandrescu and others added 9 commits September 16, 2026 12:09
…-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>
…ed mid-flight

Luis's review on #3233 flagged the "(redelivery)" label on the terminal-state
stand-down branch as misleading when a deactivate-cancel lands there. It is, and
the consequence is larger than the label: that path was leaking a Semrush
sub-workspace.

`cancelProvisioningAttempt` writes `semrush_provisioning_status = 'failed'` and
does NOT touch `attempt_id`, so an in-flight worker hop arrives with its attempt
id still current and a terminal status — the same branch a genuine SQS
redelivery reaches. The branch returns deliberately WITHOUT `cleanupIfOwned`.
Meanwhile `deactivate` only decommissions the CANONICAL pointer, which is still
null because this attempt never promoted. So a sub-workspace the chain freshly
created was orphaned upstream with nothing referencing it, consuming the
organization's allocation permanently — the exact failure class this epic exists
to prevent, reached through a door nobody had opened.

The two causes need OPPOSITE handling and are now split:

  'ready'  — a real redelivery. The candidate is by then the brand's canonical,
             live workspace, possibly holding a market project a chained job
             created. Still a true no-op; cleaning up would delete customer data.
  'failed' — this attempt never promoted, and cannot have: both
             promoteProvisioningFailed and cancelProvisioningAttempt CAS on
             `status = 'pending'`, so a promoted row can never move to 'failed'.
             Clean up if owned.

`cleanupIfOwned`'s own `freshlyCreated` guard keeps this safe: an ADOPTED
candidate belongs to another brand and is never touched. Both directions are
tested, and the fix is mutation-verified.

Also from the same review:

- The terminal-failure branch ignored `promoteProvisioningFailed`'s CAS result.
  A `false` means a newer attempt superseded this one mid-poll, so emitting at
  ERROR and returning 'failed' reported a failure the row never recorded and
  would page on a brand that may be provisioning fine. Now returns 'superseded'.
  Mutation-verified.
- He asked whether the extracted 504-recovery condition still matches
  `ensureSubworkspace`'s. It does, byte for byte, and the branch is live —
  `SerenityTransportError` is not an `ErrorWithStatusCode`. Only the explanation
  was left behind by the extraction; restored.

2112 passing, eslint and type-check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@IrisAlexandrescu
IrisAlexandrescu force-pushed the feat/LLMO-7418-provisioning-worker branch from 146216b to 4c23566 Compare September 16, 2026 09:37

@luis6156 luis6156 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review — feat(serenity): async Semrush sub-workspace provisioning worker

The worker design is sound. The CAS-everywhere discipline is rigorous — attempt-id-scoped guards on every write, the double-delivery mutex (.is('semrush_provisioning_candidate_workspace_id', null)) on persistProvisioningCandidate, and the terminal-state split in the final commit (separating 'ready' redeliveries from 'failed' stands-down, each needing opposite cleanup semantics) are all correct. The freshlyCreated threading through self-requeue metadata rather than re-deriving it from DB is the right call given the timing issues.

Three observations for the record:

1. Leaked workspace on MAX_PROVISION_REQUEUE_DEPTH exhaustion (now fixed, but worth documenting)

emptyWorkspaceBestEffort clears projects but does not delete the Semrush workspace shell itself — that's intentional, documented in workspace-lifecycle.js (failed shells can't be deleted and must remain as diagnostic records). But a not-ready workspace exhausting its 5 hops is a distinct case: the shell is likely stuck at "not ready" (not in a terminal failure state), so it may eventually settle on its own after the attempt is failed. The reconciliation pass (future work) will need to distinguish "a failed-attempt's still-pending shell" from "a terminal shell" — the distinction the Python reference already captures (creation_failed = safe to re-create, failed/error = not). No action needed now, just worth capturing as scope for the reaper phase.

2. failBestEffort swallow and stuck-pending brands

If promoteProvisioningFailed itself throws in the outer catch (e.g. a Postgres blip after a real Lambda error), the brand row stays at semrush_provisioning_status: 'pending' with no living job to revisit it. The runner marks the AsyncJob FAILED and won't redeliver; nothing automatically reconciles the brand back. This is an acknowledged gap that the reconciliation pass (referenced in the spec) will need to cover — I'm flagging it here so it's on the radar for the reaper phase, not because it's a blocker for this PR.

3. persistProvisioningCandidate's null guard on the adopted path

The .is(..., null) guard correctly handles the at-least-once first-hop race. Confirmed: on an ADOPTION, freshlyCreated === false, so the losing delivery's cleanupIfOwned correctly skips teardown of the adopted workspace (which belongs to another brand). ✓

The final commit's deactivate-cancels-attempt fix is the right approach — reusing the existing supersede mechanism rather than teaching promoteProvisioningReady brand-lifecycle semantics it doesn't otherwise need.

Approving.

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