Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/claw-frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1239,6 +1239,27 @@
line-height: 1.6;
}

/* The CTA is an INVERTED panel — its ground is `--editorial-ink`, not
* `--editorial-paper`. So any child that re-sets `color` has to take that
* colour from the inverted set too. `--editorial-signal` and
* `--editorial-graphite` are both tuned for a paper ground, and on ink they
* land at 3.4:1 and 3.0:1 in light mode, 2.3:1 and 1.8:1 in dark. Both
* themes fail WCAG AA; dark merely fails louder, which is why Lighthouse
* reported it first.
*
* The compare pages never hit this: they put bare text in the panel and let
* it inherit the panel's own colour. Only the learn and integration topic
* pages nest `__verdict-label` + `__body` inside, so only those 32 URLs
* failed the gate.
*/
.editorial-comparison__cta .editorial-comparison__verdict-label {
color: var(--editorial-paper);
}

.editorial-comparison__cta .editorial-comparison__body {
color: color-mix(in srgb, var(--editorial-paper) 85%, var(--editorial-ink));
}

.editorial-comparison__cta-actions {
display: flex;
flex-wrap: wrap;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const buildRepo = (): {
routerModelProfile: { deleteMany: jest.Mock; createMany: jest.Mock };
routerTopicProfile: { deleteMany: jest.Mock; createMany: jest.Mock };
routerWorkspacePrior: { findUnique: jest.Mock; upsert: jest.Mock };
$queryRaw: jest.Mock;
$transaction: jest.Mock;
};
} => {
Expand Down Expand Up @@ -106,6 +107,9 @@ const buildRepo = (): {
routerModelProfile,
routerTopicProfile,
routerWorkspacePrior,
// The advisory lock that serialises the replace. Returns a resolved value
// so it can sit inside the $transaction array like any PrismaPromise.
$queryRaw: jest.fn().mockResolvedValue([{ pg_advisory_xact_lock: '' }]),
// Mirrors Prisma's array-form $transaction: resolve each already-created
// PrismaPromise and return the results in order, so commitCalibrationBatch
// can destructure the created snapshot out of the batch.
Expand Down Expand Up @@ -154,6 +158,37 @@ describe('RoutingEducationRepository.commitCalibrationBatch', () => {
expect(prisma.routerTopicProfile.deleteMany).toHaveBeenCalledTimes(1);
expect(snapshot.version).toBe('calibration-2');
});

it('takes the advisory lock as the FIRST statement in the transaction', async () => {
// Regression guard. Without this lock, two concurrent rebuilds interleave:
// the second transaction's DELETE snapshot predates the first's COMMIT, so
// it removes only the rows it can see and its INSERT then violates
// `router_model_profiles_provider_model_task_family_topic_key_key`. The
// P2002 is an unhandled rejection that kills routing-service outright, and
// `rebuildCalibrationSnapshot()` runs after EVERY routing outcome, so the
// exposure is every pair of concurrent generations. Observed twice in a
// long-thread stress run on 2026-08-30.
//
// Order is the whole point: a lock taken after the DELETE would let the
// race happen before it ever blocks anything.
const { repository, prisma } = buildRepo();

await repository.commitCalibrationBatch({
version: 'calibration-3',
windowDays: 30,
summary: { decisionsAnalyzed: 1 },
promptHints: { bestModelsByTaskFamily: [] },
modelProfiles: [modelProfileRow()],
topicProfiles: [topicProfileRow()],
modelProfileRows: [modelProfileRow()],
topicProfileRows: [topicProfileRow()],
});

expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
const [operations] = prisma.$transaction.mock.calls[0] as [unknown[]];
expect(operations).toHaveLength(7);
expect(operations[0]).toBe(prisma.$queryRaw.mock.results[0]?.value);
});
});

describe('RoutingEducationRepository.restoreCalibrationSnapshot', () => {
Expand All @@ -167,6 +202,9 @@ describe('RoutingEducationRepository.restoreCalibrationSnapshot', () => {
});

expect(prisma.$transaction).toHaveBeenCalledTimes(1);
// The rollback path replaces the same tables the same way, so it is exposed
// to the same race and takes the same lock first.
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
expect(prisma.routingCalibrationSnapshot.updateMany).toHaveBeenNthCalledWith(1, {
data: { active: false },
where: { active: true },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,37 @@ export const WORKSPACE_PRIOR_BLEND_WEIGHT = 0.15;
* never touches at all).
*/
export const MAX_WORKSPACE_PRIOR_NUDGE = 0.1;

/**
* Advisory-lock key that serialises calibration commits.
*
* `commitCalibrationBatch` and `restoreCalibrationSnapshot` both replace the
* whole live profile set with `deleteMany()` followed by `createMany()`. That
* pair is not safe to run concurrently under READ COMMITTED: if the second
* transaction's DELETE takes its snapshot before the first COMMITs, it removes
* only the rows it can see, never observes the rows the first is inserting, and
* its own INSERT then violates
* `router_model_profiles_provider_model_task_family_topic_key_key`.
*
* That is not hypothetical. `rebuildCalibrationSnapshot()` runs on EVERY
* routing outcome, so every pair of concurrent generations is an opportunity,
* and the resulting P2002 is an unhandled rejection that kills the process —
* routing-service down, `/routing/models` 502, no message routable at all.
* Reproduced twice on 2026-08-30 under a long-thread stress run.
*
* The window widens as history accumulates: `findEducationWindow` returns a
* growing set of decisions, so the transaction takes longer the more the system
* has been used. It survived 24 concurrent generations on a young database and
* crashed reliably after ~1,400, which is why load testing a fresh install does
* not surface it.
*
* A transaction-scoped advisory lock makes the replace atomic against other
* writers and is released automatically on COMMIT or ROLLBACK, so a crashed
* transaction cannot strand it.
*
* Next in routing-service's 740_040_00N advisory-lock block (001 = deployment
* seed, 002 = router chain seed, 003 = model cost seed). The value must stay
* stable; it only has to be distinct from the other advisory locks held against
* this database.
*/
export const CALIBRATION_COMMIT_LOCK_KEY = 740_040_004;
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import {
Prisma,
type RouterModelProfile,
type RouterTopicProfile,
type RouterWorkspacePrior,
Expand All @@ -8,6 +9,7 @@ import {
type RoutingOutcomeRecord,
} from '../../../generated/prisma';
import { PrismaService } from '../../../infrastructure/database/prisma/prisma.service';
import { CALIBRATION_COMMIT_LOCK_KEY } from '../constants/routing-education.constants';
import type {
CommitCalibrationBatchInput,
CreateRoutingFeedbackInput,
Expand Down Expand Up @@ -143,7 +145,14 @@ export class RoutingEducationRepository {
input: CommitCalibrationBatchInput,
): Promise<RoutingCalibrationSnapshot> {
const now = new Date();
const [, snapshot] = await this.prisma.$transaction([
// Serialise the whole replace. See CALIBRATION_COMMIT_LOCK_KEY: the
// delete-then-insert below races itself under concurrent rebuilds and the
// resulting P2002 takes the process down. The lock is transaction-scoped,
// so it is released on COMMIT or ROLLBACK without any cleanup path.
const [, , snapshot] = await this.prisma.$transaction([
this.prisma.$queryRaw(
Prisma.sql`SELECT pg_advisory_xact_lock(${CALIBRATION_COMMIT_LOCK_KEY})::text`,
),
this.prisma.routingCalibrationSnapshot.updateMany({
data: { active: false },
where: { active: true },
Expand Down Expand Up @@ -184,7 +193,12 @@ export class RoutingEducationRepository {
topicProfileRows: RouterTopicProfileRecord[];
}): Promise<void> {
const now = new Date();
// Same lock as the commit path: a rollback replaces the same live tables
// the same way, so it must not interleave with a concurrent rebuild either.
await this.prisma.$transaction([
this.prisma.$queryRaw(
Prisma.sql`SELECT pg_advisory_xact_lock(${CALIBRATION_COMMIT_LOCK_KEY})::text`,
),
this.prisma.routingCalibrationSnapshot.updateMany({
data: { active: false },
where: { active: true },
Expand Down
51 changes: 49 additions & 2 deletions scripts/qa-lab/client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,22 @@ export const ALLOW_METERED = false;
let token = null;
let tokenAt = 0;

// Whoever the process is currently acting as. Kept separate from EMAIL /
// PASSWORD so the silent token refresh below renews the ACTIVE identity rather
// than snapping back to the lab account mid-run.
let activeEmail = EMAIL;
let activePassword = PASSWORD;

export async function login() {
if (EMAIL.length === 0 || PASSWORD.length === 0) {
if (activeEmail.length === 0 || activePassword.length === 0) {
throw new Error(
'QA_LAB_EMAIL and QA_LAB_PASSWORD must be set. See skills/audit-conversational-context.md.',
);
}
const res = await fetch(`${BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
body: JSON.stringify({ email: activeEmail, password: activePassword }),
});
if (!res.ok) throw new Error(`login failed ${res.status} ${await res.text()}`);
const body = await res.json();
Expand All @@ -38,6 +44,47 @@ export async function login() {
return body.user;
}

/**
* Act as a different user for every subsequent call.
*
* Memories are USER-scoped and are injected into every thread that user owns,
* so an account that has accumulated fixtures from earlier runs quietly
* contaminates later ones: a stored "the internal project codename is ORCHID-…"
* competes with the codename the scenario seeds in the thread, and the model
* picks one. Measured on the shared lab account — three recall probes at
* distances 4, 24 and 56 all failed at exactly the same rate, which is the
* signature of a constant competing fact rather than of context loss.
*
* A long run therefore belongs on its own fresh account, where the only thing
* in scope is the conversation being measured.
*/
export async function loginAs(email, password) {
activeEmail = email;
activePassword = password;
token = null;
tokenAt = 0;
return login();
}

/** Mint a brand-new empty account (admin credentials required). */
export async function createIsolatedUser(tag) {
const suffix = `${tag}${Date.now().toString(36)}`;
const account = {
email: `qa-${suffix}@claw.local`,
username: `qa${suffix}`.replace(/[^a-z0-9]/gi, '').slice(0, 28),
password: 'QaIsolated123!',
firstName: 'QA',
lastName: 'Isolated',
};
await login();
const created = await api('POST', '/users', account);
if (!created.ok) {
throw new Error(`createIsolatedUser failed ${created.status} ${JSON.stringify(created.body)}`);
}
const user = await loginAs(account.email, account.password);
return { ...account, id: user.id };
}

async function ensureToken() {
// access token lives 900s; refresh well before the edge
if (token === null || Date.now() - tokenAt > 600_000) await login();
Expand Down
11 changes: 10 additions & 1 deletion scripts/qa-lab/memory-experiment.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,17 @@ for (const row of rows) {
}
console.log(`\n${rows.filter((r) => r.pass).length}/${rows.length} passed`);

// Forgetting a memory is confirmation-gated (`FORGET_CONFIRMATION_REQUIRED`),
// and this cleanup used to omit the flag and ignore the result. The 400 was
// silent, so every run left its standing INSTRUCTION behind — and the NEXT run
// then had two conflicting "always end your reply with …" memories live at
// once, obeyed the older one, and reported a product failure that was really
// its own litter. A cleanup whose failure is invisible is not a cleanup.
if (memoryId !== null) {
await api('DELETE', `/memories/${memoryId}`);
const forgotten = await api('DELETE', `/memories/${memoryId}?confirm=FORGET`);
if (!forgotten.ok) {
console.log(`WARNING: could not forget ${memoryId} (${String(forgotten.status)}) — next run may see it`);
}
await sleep(200);
}
writeJson(`${OUT}/summary.json`, { runId: RUN_ID, threadId: thread.id, memoryId, rows, answer });
Expand Down
Loading
Loading