From 299bbeed129bac88d5bebea48c6d3c592fd32a8d Mon Sep 17 00:00:00 2001 From: Dmitriy Vasilev Date: Mon, 21 Sep 2026 19:40:56 +0700 Subject: [PATCH 1/2] fix(queen): create the Queen schema before migrating; mend five red tests The migrations failed on every fresh database: the pool pins search_path to queenSchema() ('trios') and nothing created that schema, so a new environment, a restore or CI got 'no schema has been selected to create in' on every statement. runPgMigrations now runs CREATE SCHEMA IF NOT EXISTS first; reproduced against PostgreSQL 16 (old code 2 fail, new code 3 pass), and the live gate now inspects that schema instead of 'public'. Tests that were red on every PR into this branch: - queen-candidates: read the checkout it runs in, not /Users/playra/BrowserOS - queen-salvage-guards: two tests need queend for a verdict, gated like queen-adversarial-review; the fake gets a measurement budget of 1 - queen-salvage: the merge meant to conflict now has an identity; a CI runner hostname with no domain made git refuse it before the index, and the test now asserts the conflict (UU) actually happened - navigation/windows: newer BrowserOS says 'Hidden windows are no longer supported'; the skip helper accepts both wordings - dom search_dom XPath: polls up to 5s instead of racing the page load Co-Authored-By: Claude Opus 5 --- .../apps/server/src/lib/db/pg-migrate.ts | 10 +- .../server/tests/api/queen-candidates.test.ts | 15 ++- .../tests/api/queen-salvage-guards.test.ts | 108 ++++++++++-------- .../server/tests/api/queen-salvage.test.ts | 18 ++- .../tests/pglive/pg-migrate-live.test.ts | 9 +- .../apps/server/tests/tools/dom.test.ts | 16 ++- .../server/tests/tools/navigation.test.ts | 15 ++- .../apps/server/tests/tools/windows.test.ts | 21 +++- 8 files changed, 149 insertions(+), 63 deletions(-) diff --git a/trios/agent-server/apps/server/src/lib/db/pg-migrate.ts b/trios/agent-server/apps/server/src/lib/db/pg-migrate.ts index be0c216bcb..c74b3d7425 100644 --- a/trios/agent-server/apps/server/src/lib/db/pg-migrate.ts +++ b/trios/agent-server/apps/server/src/lib/db/pg-migrate.ts @@ -9,7 +9,7 @@ import type { Pool } from 'pg' import { logger } from '../logger' -import { createQueenPool } from './queen-pool' +import { createQueenPool, queenSchema } from './queen-pool' function getDatabaseUrl(): string | undefined { return process.env.DATABASE_URL || process.env.RAILWAY_SSOT_URL || undefined @@ -303,6 +303,14 @@ export async function runPgMigrations(): Promise { const pool = createPool(databaseUrl) try { + // The pool pins search_path to queenSchema() on every connection, and + // nothing created that schema: production had it from a hand-typed + // command, so the migrations only ever ran where it already existed. On a + // fresh database - CI, a restore, a new environment - every statement + // below failed with "no schema has been selected to create in" (measured + // in the pglive gate, 2026-09-21). queenSchema() is validated as a plain + // identifier before it is interpolated. + await pool.query(`CREATE SCHEMA IF NOT EXISTS ${queenSchema()}`) await pool.query(MIGRATION_SQL) logger.info('PostgreSQL migrations completed successfully') } catch (error) { diff --git a/trios/agent-server/apps/server/tests/api/queen-candidates.test.ts b/trios/agent-server/apps/server/tests/api/queen-candidates.test.ts index 01e712dd2a..41d6424ee2 100644 --- a/trios/agent-server/apps/server/tests/api/queen-candidates.test.ts +++ b/trios/agent-server/apps/server/tests/api/queen-candidates.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'bun:test' +import { resolve } from 'node:path' import { deriveCandidates, FILE_LENGTH_THRESHOLD, @@ -19,6 +20,12 @@ import { * it carries the one thing a candidate needs to be delegatable - a boundary, * which is the file itself. */ +// The checkout this test runs in, not one developer's home directory: the +// path used to be /Users/playra/BrowserOS, which exists on one laptop, so on +// every other machine and in CI readdir found nothing and three tests failed +// on an empty list. +const REPO_ROOT = resolve(import.meta.dir, '../../../../../..') + describe('deriving work the repository already measured', () => { const fake = (sizes: Record) => async (path: string) => { const key = Object.keys(sizes).find((k) => path.endsWith(k)) @@ -38,7 +45,7 @@ describe('deriving work the repository already measured', () => { // Evidence, not opinion: the candidate must carry the command that produced // it, or a reader cannot tell a measurement from a preference. it('carries the command that produced it', async () => { - const out = await deriveCandidates('/Users/playra/BrowserOS') + const out = await deriveCandidates(REPO_ROOT) // Not vacuous. A `for` over an empty list passes and proves nothing, which // is the exact shape of test this session has caught three times. expect(out.length).toBeGreaterThan(0) @@ -52,7 +59,7 @@ describe('deriving work the repository already measured', () => { // The boundary is the file. A candidate with no path is not delegatable and // must never be produced. it('gives every candidate a path that can be a boundary', async () => { - const out = await deriveCandidates('/Users/playra/BrowserOS') + const out = await deriveCandidates(REPO_ROOT) expect(out.length).toBeGreaterThan(0) for (const c of out) { expect(c.path.startsWith('agent-server/')).toBe(true) @@ -64,7 +71,7 @@ describe('deriving work the repository already measured', () => { // runtime, klavis - and splitting them would create merge pain in code this // project does not own for a gate it did not write. it('proposes nothing from code this project does not own', async () => { - const out = await deriveCandidates('/Users/playra/BrowserOS') + const out = await deriveCandidates(REPO_ROOT) expect(out.length).toBeGreaterThan(0) for (const c of out) { expect(c.path).not.toContain('openclaw') @@ -74,7 +81,7 @@ describe('deriving work the repository already measured', () => { }) it('puts the longest first, which is the one the gate complains about most', async () => { - const out = await deriveCandidates('/Users/playra/BrowserOS') + const out = await deriveCandidates(REPO_ROOT) for (let i = 1; i < out.length; i++) { expect(out[i - 1].lines).toBeGreaterThanOrEqual(out[i].lines) } diff --git a/trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts b/trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts index 5f544fd560..a533efeecc 100644 --- a/trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts +++ b/trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts @@ -49,6 +49,9 @@ import { noteWithSalvage, reviewFinishedDispatches, } from '../../src/api/services/queen-tick' +import { resolveQueendPath } from '../__helpers__/queend-path' + +const queendPresent = existsSync(resolveQueendPath()) const REAL_GIT = Bun.which('git') || '/usr/bin/git' const ISSUE = 1627 @@ -724,7 +727,10 @@ function reviewDeps(reviewerText: string) { }), laneCandidates: () => [LANE], reviewsPerRound: () => 1, - measurementsPerRound: () => 0, + // One, not zero: a review now waits for its criteria to be measured, so a + // zero budget turned every row here into `wait` and the reviewer was + // never asked. The fake measurement (branchDeps) returns no runs. + measurementsPerRound: () => 1, llm: async () => ({ ok: true as const, text: reviewerText }), } } @@ -747,26 +753,33 @@ const LONG_REFUTATION = [ ].join('\n') describe('the note says who committed the branch, where it can be read', () => { - it('puts the salvage fact where the 1500-character cap cannot cut it', async () => { - const salvagedRow = finishedRow({ - salvaged_at: new Date(), - salvaged_sha: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', - salvaged_files: ['trios/docs/note.md'], - salvage_left: ['README.md'], - }) - const pool = reviewPool(salvagedRow) - await reviewFinishedDispatches(pool.pool, { - ...reviewDeps(LONG_REFUTATION), - ...branchDeps(), - } as unknown as Parameters[1]) - - const note = String(pool.verdict()?.params[2] ?? '') - // Stored at the cap, which is the case the fact used to be lost in... - expect(note.length).toBe(1500) - // ...and the fact is still there, because it goes first. - expect(note).toContain('committed by the container') - expect(note.indexOf('committed by the container')).toBeLessThan(400) - }) + // The verdict is queend's (the Swift policy binary): without it every row + // stays `wait` and the reviewer's refutation never becomes a send-back, so + // this needs the binary exactly as queen-adversarial-review.test.ts does. + // It failed in CI for that reason alone - CI builds no queend. + it.if(queendPresent)( + 'puts the salvage fact where the 1500-character cap cannot cut it', + async () => { + const salvagedRow = finishedRow({ + salvaged_at: new Date(), + salvaged_sha: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + salvaged_files: ['trios/docs/note.md'], + salvage_left: ['README.md'], + }) + const pool = reviewPool(salvagedRow) + await reviewFinishedDispatches(pool.pool, { + ...reviewDeps(LONG_REFUTATION), + ...branchDeps(), + } as unknown as Parameters[1]) + + const note = String(pool.verdict()?.params[2] ?? '') + // Stored at the cap, which is the case the fact used to be lost in... + expect(note.length).toBe(1500) + // ...and the fact is still there, because it goes first. + expect(note).toContain('committed by the container') + expect(note.indexOf('committed by the container')).toBeLessThan(400) + }, + ) // The composer itself: provenance first, body second, and a body alone when // nothing was salvaged. @@ -784,29 +797,32 @@ describe('a turn the provider killed does not spend the issue', () => { // minutes, not evidence about the issue". The salvage removed that // coincidence: the container commits the killed turn's edits, files.length // is non-zero, and the attempt arrives on the MAIN path, which charged it. - it('is judged and sent back, but charges neither counter', async () => { - const killed = finishedRow({ - outcome: 'the stream ended without a completion', - said: '', - salvaged_at: new Date(), - salvaged_sha: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', - salvaged_files: ['trios/docs/note.md'], - salvage_left: [], - }) - const pool = reviewPool(killed) - const result = await reviewFinishedDispatches(pool.pool, { - ...reviewDeps(LONG_REFUTATION), - ...branchDeps(), - } as unknown as Parameters[1]) - - // The work IS judged - that is the whole point of salvaging it... - expect(result.acted).toEqual([`#${ISSUE}:sendBack`]) - const params = pool.verdict()?.params ?? [] - // ...and it is not escalated past the bee (`beyondThePatch`)... - expect(String(params[1])).toBe('sendBack') - // ...and `send_backs` does not move: countsAgainstTheIssue is false... - expect(params[4]).toBe(false) - // ...and neither does free_attempts. - expect(params[5]).toBe(0) - }) + it.if(queendPresent)( + 'is judged and sent back, but charges neither counter', + async () => { + const killed = finishedRow({ + outcome: 'the stream ended without a completion', + said: '', + salvaged_at: new Date(), + salvaged_sha: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + salvaged_files: ['trios/docs/note.md'], + salvage_left: [], + }) + const pool = reviewPool(killed) + const result = await reviewFinishedDispatches(pool.pool, { + ...reviewDeps(LONG_REFUTATION), + ...branchDeps(), + } as unknown as Parameters[1]) + + // The work IS judged - that is the whole point of salvaging it... + expect(result.acted).toEqual([`#${ISSUE}:sendBack`]) + const params = pool.verdict()?.params ?? [] + // ...and it is not escalated past the bee (`beyondThePatch`)... + expect(String(params[1])).toBe('sendBack') + // ...and `send_backs` does not move: countsAgainstTheIssue is false... + expect(params[4]).toBe(false) + // ...and neither does free_attempts. + expect(params[5]).toBe(0) + }, + ) }) diff --git a/trios/agent-server/apps/server/tests/api/queen-salvage.test.ts b/trios/agent-server/apps/server/tests/api/queen-salvage.test.ts index dcb2342e7d..260ada3156 100644 --- a/trios/agent-server/apps/server/tests/api/queen-salvage.test.ts +++ b/trios/agent-server/apps/server/tests/api/queen-salvage.test.ts @@ -371,7 +371,23 @@ describe('salvaging a turn that ended with its work uncommitted', () => { ]) git(f.worktree, ['fetch', '-q', 'origin', 'theirs:refs/remotes/origin/x']) // The merge is MEANT to fail: that is how the unmerged entry gets there. - expect(tryGit(f.worktree, ['merge', 'refs/remotes/origin/x'])).not.toBe(0) + // With an identity, like the commit above. GIT_CONFIG_GLOBAL is /dev/null + // here, so git guesses user@hostname - which works on a laptop named + // `x.local` and is refused on a CI runner named `runnervmlun5p` ("unable + // to auto-detect email address"). The merge then failed for the wrong + // reason, before touching the index, and left a clean worktree. + expect( + tryGit(f.worktree, [ + '-c', + 'user.email=bee@example.com', + '-c', + 'user.name=Bee', + 'merge', + 'refs/remotes/origin/x', + ]), + ).not.toBe(0) + // And it failed the RIGHT way: the index holds the conflict. + expect(git(f.worktree, ['status', '--porcelain'])).toContain('UU ') const before = f.log() const result = await salvageWorktree( diff --git a/trios/agent-server/apps/server/tests/pglive/pg-migrate-live.test.ts b/trios/agent-server/apps/server/tests/pglive/pg-migrate-live.test.ts index 5fe7de0b80..99de3e7e2d 100644 --- a/trios/agent-server/apps/server/tests/pglive/pg-migrate-live.test.ts +++ b/trios/agent-server/apps/server/tests/pglive/pg-migrate-live.test.ts @@ -37,6 +37,7 @@ import { randomBytes } from 'node:crypto' import { userInfo } from 'node:os' import { Pool } from 'pg' import { MIGRATION_SQL, runPgMigrations } from '../../src/lib/db/pg-migrate' +import { queenSchema } from '../../src/lib/db/queen-pool' import { logger } from '../../src/lib/logger' import { factsFor } from '../api/pg-migrate-sql-facts' @@ -182,11 +183,15 @@ describe('the migration block, applied to a real PostgreSQL', () => { const pool = new Pool({ connectionString: scratchUrl, max: 1 }) try { + // The schema the pool pins, not 'public': the tables live where + // every Queen connection's search_path points. const columns = await pool.query( - "SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = 'public'", + 'SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = $1', + [queenSchema()], ) const indexes = await pool.query( - "SELECT indexname, tablename, indexdef FROM pg_indexes WHERE schemaname = 'public'", + 'SELECT indexname, tablename, indexdef FROM pg_indexes WHERE schemaname = $1', + [queenSchema()], ) inspection = { columns: columns.rows as Record[], diff --git a/trios/agent-server/apps/server/tests/tools/dom.test.ts b/trios/agent-server/apps/server/tests/tools/dom.test.ts index 438d9781b1..488073852f 100644 --- a/trios/agent-server/apps/server/tests/tools/dom.test.ts +++ b/trios/agent-server/apps/server/tests/tools/dom.test.ts @@ -356,10 +356,24 @@ describe('search_dom', () => { const newResult = await execute(new_page, { url: RICH_PAGE }) const pageId = pageIdOf(newResult) - const result = await execute(search_dom, { + // Asked until the page has rendered, for up to five seconds: once, it + // raced the load and failed in CI on one run of two (2026-09-21) with + // the same page and the same query. + let result = await execute(search_dom, { page: pageId, query: '//button[@type="submit"]', }) + for ( + let tries = 0; + tries < 10 && !result.isError && !textOf(result).includes('Found'); + tries++ + ) { + await new Promise((resolve) => setTimeout(resolve, 500)) + result = await execute(search_dom, { + page: pageId, + query: '//button[@type="submit"]', + }) + } assert.ok(!result.isError, textOf(result)) const text = textOf(result) assert.ok(text.includes('Found'), 'Should find the submit button') diff --git a/trios/agent-server/apps/server/tests/tools/navigation.test.ts b/trios/agent-server/apps/server/tests/tools/navigation.test.ts index 04307698a5..f78b9942bf 100644 --- a/trios/agent-server/apps/server/tests/tools/navigation.test.ts +++ b/trios/agent-server/apps/server/tests/tools/navigation.test.ts @@ -28,7 +28,6 @@ function structuredOf(result: { structuredContent?: unknown }): T { return result.structuredContent as T } - /** * Did the browser refuse because this platform cannot hide a window at all? * @@ -51,7 +50,14 @@ function structuredOf(result: { structuredContent?: unknown }): T { * and `move_page` refuses it. That is a product question, not a fixture one, * and it is recorded rather than guessed at. */ -const HIDDEN_UNSUPPORTED = 'Hidden windows are not yet supported on this platform' +const HIDDEN_UNSUPPORTED = + 'Hidden windows are not yet supported on this platform' +// Newer BrowserOS builds say the second one (seen in CI 2026-09-21); both mean +// the platform cannot open a hidden window, which is a skip, not a failure. +const HIDDEN_UNSUPPORTED_TEXTS = [ + HIDDEN_UNSUPPORTED, + 'Hidden windows are no longer supported', +] /** Set by the tests below; read by the last test in this file. */ const hiddenGate = { ran: false, skipped: false } @@ -60,7 +66,10 @@ function skipIfHiddenUnsupported(result: { isError?: boolean content: { type: string; text?: string }[] }): boolean { - if (result.isError && textOf(result).includes(HIDDEN_UNSUPPORTED)) { + if ( + result.isError && + HIDDEN_UNSUPPORTED_TEXTS.some((text) => textOf(result).includes(text)) + ) { hiddenGate.skipped = true console.error( ` HIDDEN-WINDOW TESTS SKIPPED: ${HIDDEN_UNSUPPORTED}.\n` + diff --git a/trios/agent-server/apps/server/tests/tools/windows.test.ts b/trios/agent-server/apps/server/tests/tools/windows.test.ts index 2d976a9262..b3bb13f422 100644 --- a/trios/agent-server/apps/server/tests/tools/windows.test.ts +++ b/trios/agent-server/apps/server/tests/tools/windows.test.ts @@ -39,7 +39,6 @@ function fakeWindow( } } - /** * Did the browser refuse because this platform cannot hide a window at all? * @@ -56,7 +55,14 @@ function fakeWindow( * * The same guard, for the same reason, is in tests/tools/navigation.test.ts. */ -const HIDDEN_UNSUPPORTED = 'Hidden windows are not yet supported on this platform' +const HIDDEN_UNSUPPORTED = + 'Hidden windows are not yet supported on this platform' +// Newer BrowserOS builds say the second one (seen in CI 2026-09-21); both mean +// the platform cannot open a hidden window, which is a skip, not a failure. +const HIDDEN_UNSUPPORTED_TEXTS = [ + HIDDEN_UNSUPPORTED, + 'Hidden windows are no longer supported', +] /** Set below; read by the last test in this file. */ const hiddenGate = { ran: false, skipped: false } @@ -65,12 +71,15 @@ function skipIfHiddenUnsupported(result: { isError?: boolean content: { type: string; text?: string }[] }): boolean { - if (result.isError && textOf(result).includes(HIDDEN_UNSUPPORTED)) { + if ( + result.isError && + HIDDEN_UNSUPPORTED_TEXTS.some((text) => textOf(result).includes(text)) + ) { hiddenGate.skipped = true console.error( ` HIDDEN-WINDOW TEST SKIPPED: ${HIDDEN_UNSUPPORTED}.\n` + ' A platform limit, not a failure, and counted by the last test in this\n' + - ' file so the absence is in the output rather than in nobody else\'s head.\n', + " file so the absence is in the output rather than in nobody else's head.\n", ) return true } @@ -214,7 +223,9 @@ describe('window tools', () => { // success it never earned; this one either ran or said why it did not. it('the hidden-window test ran, or its absence is on the record', () => { if (hiddenGate.skipped) { - console.error(' HIDDEN-WINDOW GATE: SKIPPED - this platform cannot hide a window.\n') + console.error( + ' HIDDEN-WINDOW GATE: SKIPPED - this platform cannot hide a window.\n', + ) return } assert.ok( From 48677664486e9326903863f7da7d0817cb01049f Mon Sep 17 00:00:00 2001 From: Dmitriy Vasilev Date: Mon, 21 Sep 2026 20:11:21 +0700 Subject: [PATCH 2/2] test(tools): search_dom tests wait for the page through one helper A second search_dom test (nodeId) raced the page load in CI the run after the XPath one was fixed. Both now go through searchUntil, which asks for up to five seconds until the answer carries what the test looks for. Co-Authored-By: Claude Opus 5 --- .../apps/server/tests/tools/dom.test.ts | 58 ++++++++++++------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/trios/agent-server/apps/server/tests/tools/dom.test.ts b/trios/agent-server/apps/server/tests/tools/dom.test.ts index 488073852f..d487a8f2cb 100644 --- a/trios/agent-server/apps/server/tests/tools/dom.test.ts +++ b/trios/agent-server/apps/server/tests/tools/dom.test.ts @@ -79,6 +79,30 @@ function cleanupSavedDom(domPath: string): void { // ── get_dom ── +/** + * search_dom, asked until its answer contains `marker` or five seconds pass. + * new_page can return before the page has rendered, and a single search then + * races the load: two of these tests failed in CI on one run each + * (2026-09-21) with the same page and query that passed on the next. + */ +async function searchUntil( + execute: Parameters[0]>[0]['execute'], + page: number, + query: string, + marker: string, +) { + let result = await execute(search_dom, { page, query }) + for ( + let tries = 0; + tries < 10 && !result.isError && !textOf(result).includes(marker); + tries++ + ) { + await new Promise((resolve) => setTimeout(resolve, 500)) + result = await execute(search_dom, { page, query }) + } + return result +} + describe('get_dom', () => { it('returns full page HTML', async () => { await withBrowser(async ({ execute }) => { @@ -356,24 +380,12 @@ describe('search_dom', () => { const newResult = await execute(new_page, { url: RICH_PAGE }) const pageId = pageIdOf(newResult) - // Asked until the page has rendered, for up to five seconds: once, it - // raced the load and failed in CI on one run of two (2026-09-21) with - // the same page and the same query. - let result = await execute(search_dom, { - page: pageId, - query: '//button[@type="submit"]', - }) - for ( - let tries = 0; - tries < 10 && !result.isError && !textOf(result).includes('Found'); - tries++ - ) { - await new Promise((resolve) => setTimeout(resolve, 500)) - result = await execute(search_dom, { - page: pageId, - query: '//button[@type="submit"]', - }) - } + const result = await searchUntil( + execute, + pageId, + '//button[@type="submit"]', + 'Found', + ) assert.ok(!result.isError, textOf(result)) const text = textOf(result) assert.ok(text.includes('Found'), 'Should find the submit button') @@ -571,10 +583,12 @@ describe('search_dom', () => { const newResult = await execute(new_page, { url: RICH_PAGE }) const pageId = pageIdOf(newResult) - const result = await execute(search_dom, { - page: pageId, - query: '#submit-btn', - }) + const result = await searchUntil( + execute, + pageId, + '#submit-btn', + 'nodeId:', + ) assert.ok(!result.isError, textOf(result)) const text = textOf(result) assert.ok(