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
10 changes: 9 additions & 1 deletion trios/agent-server/apps/server/src/lib/db/pg-migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -303,6 +303,14 @@ export async function runPgMigrations(): Promise<void> {

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) {
Expand Down
15 changes: 11 additions & 4 deletions trios/agent-server/apps/server/tests/api/queen-candidates.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'bun:test'
import { resolve } from 'node:path'
import {
deriveCandidates,
FILE_LENGTH_THRESHOLD,
Expand All @@ -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<string, number>) => async (path: string) => {
const key = Object.keys(sizes).find((k) => path.endsWith(k))
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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')
Expand All @@ -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)
}
Expand Down
108 changes: 62 additions & 46 deletions trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }),
}
}
Expand All @@ -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<typeof reviewFinishedDispatches>[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<typeof reviewFinishedDispatches>[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.
Expand All @@ -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<typeof reviewFinishedDispatches>[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<typeof reviewFinishedDispatches>[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)
},
)
})
18 changes: 17 additions & 1 deletion trios/agent-server/apps/server/tests/api/queen-salvage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<string, string>[],
Expand Down
44 changes: 36 additions & 8 deletions trios/agent-server/apps/server/tests/tools/dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Parameters<typeof withBrowser>[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 }) => {
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 12 additions & 3 deletions trios/agent-server/apps/server/tests/tools/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ function structuredOf<T>(result: { structuredContent?: unknown }): T {
return result.structuredContent as T
}


/**
* Did the browser refuse because this platform cannot hide a window at all?
*
Expand All @@ -51,7 +50,14 @@ function structuredOf<T>(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 }
Expand All @@ -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` +
Expand Down
Loading
Loading