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..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,10 +380,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: '//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') @@ -557,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( 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(