Skip to content

CL-6375: fix routine re-creation and repeated announcements - #121

Merged
TheGreatAxios merged 2 commits into
mainfrom
cl-6375-routine-idempotency
Aug 20, 2026
Merged

TheGreatAxios merged 2 commits into
mainfrom
cl-6375-routine-idempotency

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Root cause

ensureDefaultRoutines (packages/hub-client/src/default-routines.ts), which
seeds every tenant's default routine presets ("Daily digest",
"Last 30 days research"), guarded against duplicates with a
list-existing-then-create-if-missing check — a TOCTOU race, not a real
idempotency guarantee, with no unique constraint behind it.

provisionPersonalTenantIfNeeded (packages/onboarding/src/provision.ts) can
call seedTenant/ensureDefaultRoutines again on a subsequent sign-in
whenever isFullySeeded is still false, and pending-seed.ts's own header
comment explicitly allows two overlapping "finish setup" calls to both reach
the seed step. Two such calls can both pass the list-check (both see zero
existing routines), both POST, and both succeed: two "Daily digest" routine
rows, each with its own auto-provisioned delivery workbench, each posting its
own "Created routine ..." notice into chat.

Fix

  • routine gets a nullable preset_key column, unique per
    (tenant_id, preset_key) while the row is live (migration 0005,
    packages/routines/src/migrations.ts).
  • RoutineStore gets createRoutineIfAbsent: one atomic
    INSERT ... ON CONFLICT DO NOTHING against that index
    (packages/routines/src/store.ts), so two concurrent creates for the same
    (tenantId, presetKey) are guaranteed exactly one winner.
  • POST /routines accepts an optional presetKey. When present, the loser
    of a create-if-absent race gets a 200 back with the winner's own row —
    never a second insert, never a fire, never an announcement — and
    compensates (deletes) whatever delivery workbench it had already
    provisioned before losing (packages/routines/src/routes.ts). Plain,
    non-preset creates (Myra's routine_create tool, the routines UI) are
    untouched — presetKey is opt-in.
  • ensureDefaultRoutines now sends each preset's stable assetName as
    presetKey and treats a 200 the same as a local "already exists" skip.
    Its own list-then-check is kept as a fast path only; the unique index is
    what actually prevents the duplicate now.

Tests

  • packages/routines/test/routes.test.ts: a repeated/racing presetKey
    create returns one 201 + any number of 200s, never a second row, posts
    the "Created routine" notice exactly once, and compensates the loser's
    freshly-provisioned delivery workbench. A plain create with no presetKey
    is unaffected.
  • packages/hub-client/test/default-routines.test.ts: presetKey is sent as
    each preset's assetName; a 200 (lost the race) is read as
    already-seeded, not re-disabled.

Scoped bun test + tsc --noEmit green for packages/routines,
packages/hub-client, packages/onboarding, and apps/hub. The
DB-gated migration/drizzle-store suites (store.drizzle.test.ts,
migrations.test.ts) skipped locally — no reachable Postgres in this
environment — same skip condition as before this change.

Note for CL-6362

Did not touch any routines page UI files; this is the seed/data side only.
packages/routines/src/routes.ts and store.ts are shared plumbing the
routines page also depends on, but no UI file changed.

Covers a template-minted routine (presetKey) never producing a second
row or a second "Created routine" notice on a repeat/racing create,
the losing request compensating its own freshly-provisioned delivery
workbench, and that a plain (non-preset) create is unaffected.
Root cause: ensureDefaultRoutines (default-routines.ts) seeded each
default preset ("Daily digest", "Last 30 days research") with a
"list existing routines, then POST if missing" check — a TOCTOU race,
not a DB-enforced idempotency guarantee, with no unique constraint
backing it. Two overlapping seed calls (onboarding's own "finish
setup" allows this deliberately, per pending-seed.ts) can both pass
the list check and both create a routine row, each provisioning its
own delivery workbench and each posting a "Created routine" notice —
exactly the duplicate routines, duplicate digest workbenches, and
repeated chat announcements reported in CL-6375.

Adds a nullable presetKey column to routine, unique per
(tenant_id, preset_key) while live (migrations.ts 0005). POST
/routines now accepts an optional presetKey and, when present, calls
the store's new createRoutineIfAbsent: a single INSERT ... ON
CONFLICT DO NOTHING against that index, so two concurrent creates for
the same preset are guaranteed exactly one winner. The loser gets a
200 back with the winner's own row (never a second insert, never a
fire, never a notice) and compensates (deletes) the delivery space it
had already provisioned before losing the race. Plain, non-preset
creates (Myra's own routine_create tool, the routines UI) are
unaffected — presetKey is opt-in.

ensureDefaultRoutines now sends each preset's stable assetName as
presetKey and treats a 200 response the same as a local "already
exists" skip. Its list-then-check remains as a fast path only; the
unique index is what actually prevents the duplicate now.
@TheGreatAxios
TheGreatAxios merged commit 58e47dd into main Aug 20, 2026
0 of 2 checks passed
@TheGreatAxios
TheGreatAxios deleted the cl-6375-routine-idempotency branch August 25, 2026 15:29
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