From ac93c9bdd840275765abc7794b0d0abfe2a9db29 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasilev Date: Wed, 23 Sep 2026 13:55:51 +0700 Subject: [PATCH] feat(queen): the leaderboard is all time, and a window narrows it The board asked for thirty days, which is the wrong default for a record of who carried the swarm. A lender whose lanes worked hard last month and rested this one read as having done nothing, and the board quietly SHRANK as time passed rather than growing with the work. Owner's word today: show the whole record. `days` is now `number | null`, null meaning no time predicate at all rather than a very large number of days - so the query has nothing to be off-by-one about, and the archive's own rows decide how far back the answer goes. `?days=N` still narrows it, up to ten years. A `days` that is not a number is treated as a mistake in the request rather than a request for everything: only an ABSENT parameter means the whole record. Two tests pin the branch SQL decides rather than TypeScript, with a fake pool: the default writes no `interval` and passes no parameter, and `days=30` narrows BOTH halves - the live dispatches and the archive - because a half-windowed answer would silently undercount every retry. 11 tests pass. Co-Authored-By: Claude Opus 5 --- .../api/routes/queen-public-leaderboard.ts | 25 +++++++---- .../src/api/services/queen-leaderboard.ts | 33 +++++++++++---- .../tests/api/queen-leaderboard.test.ts | 41 +++++++++++++++++++ 3 files changed, 85 insertions(+), 14 deletions(-) diff --git a/trios/agent-server/apps/server/src/api/routes/queen-public-leaderboard.ts b/trios/agent-server/apps/server/src/api/routes/queen-public-leaderboard.ts index 27b35b41b1..728462d22a 100644 --- a/trios/agent-server/apps/server/src/api/routes/queen-public-leaderboard.ts +++ b/trios/agent-server/apps/server/src/api/routes/queen-public-leaderboard.ts @@ -21,16 +21,27 @@ import { createQueenPool } from '../../lib/db/queen-pool' import { logger } from '../../lib/logger' import { leaderboard } from '../services/queen-leaderboard' -/** The window, in days. A month covers the swarm's memory of its own turns. */ -const DEFAULT_DAYS = 30 -const MAX_DAYS = 90 +/** + * ALL TIME BY DEFAULT. `?days=N` narrows it. + * + * This answered for thirty days until 2026-09-23, which was the wrong default + * for a record of who carried the swarm: a lender whose lanes worked hard last + * month and rested this one read as having done nothing, and the board quietly + * shrank as time passed rather than growing with the work. + */ +const MAX_DAYS = 3650 export function createQueenPublicLeaderboardRoute() { return new Hono().get('/', async (c) => { - const asked = Number(c.req.query('days')) - const days = Number.isFinite(asked) - ? Math.min(MAX_DAYS, Math.max(1, Math.trunc(asked))) - : DEFAULT_DAYS + const raw = c.req.query('days') + const asked = Number(raw) + // No `days` at all means the whole record; a `days` that is not a number is + // a mistake in the request rather than a request for everything, so it is + // treated as absent only when it was absent. + const days = + raw === undefined || !Number.isFinite(asked) + ? null + : Math.min(MAX_DAYS, Math.max(1, Math.trunc(asked))) const url = process.env.DATABASE_URL if (!url) return c.json({ error: 'No database configured' }, 503) try { diff --git a/trios/agent-server/apps/server/src/api/services/queen-leaderboard.ts b/trios/agent-server/apps/server/src/api/services/queen-leaderboard.ts index b5cc563a1a..41ff3cddac 100644 --- a/trios/agent-server/apps/server/src/api/services/queen-leaderboard.ts +++ b/trios/agent-server/apps/server/src/api/services/queen-leaderboard.ts @@ -148,20 +148,35 @@ export function rank( * the archive is where the turns before it live; leaving it out counted the * last attempt of each issue and called it the swarm's whole history. */ -export async function keyWork(pool: Pool, days = 30): Promise { +export async function keyWork( + pool: Pool, + days: number | null = null, +): Promise { + // ALL TIME IS THE DEFAULT, and `days` narrows it. + // + // A thirty-day window was the first shape of this and it was the wrong one: + // the board is a record of who carried the swarm, and a record that forgets + // last month tells a lender their work stopped counting. It also shrinks on + // its own - a lane that worked hard in August and stopped simply vanished, + // which reads as "did nothing" rather than "did this, earlier". + // + // `null` means no time predicate at all rather than a very large number of + // days, so the query has nothing to be off-by-one about, and the archive's + // own rows decide how far back the answer goes. + const windowed = days !== null const { rows } = await pool.query( `WITH turns AS ( SELECT key_index, review_state, dispatched_at, finished_at FROM queen_dispatch - WHERE dispatched_at > now() - ($1::integer * interval '1 day') + ${windowed ? "WHERE dispatched_at > now() - ($1::integer * interval '1 day')" : ''} UNION ALL SELECT (snapshot->>'key_index')::integer, snapshot->>'review_state', (snapshot->>'dispatched_at')::timestamptz, (snapshot->>'finished_at')::timestamptz FROM queen_dispatch_history - WHERE archived_at > now() - ($1::integer * interval '1 day') - AND snapshot->>'key_index' ~ '^[0-9]+$' + WHERE snapshot->>'key_index' ~ '^[0-9]+$' + ${windowed ? "AND archived_at > now() - ($1::integer * interval '1 day')" : ''} ) SELECT key_index, count(*) FILTER (WHERE review_state = 'accept') AS accepted, @@ -175,7 +190,7 @@ export async function keyWork(pool: Pool, days = 30): Promise { WHERE key_index IS NOT NULL GROUP BY key_index ORDER BY key_index`, - [days], + windowed ? [days] : [], ) return rows.map((row) => ({ keyIndex: Number(row.key_index), @@ -186,14 +201,18 @@ export async function keyWork(pool: Pool, days = 30): Promise { } export interface Leaderboard { - days: number + /** The window in days, or null for the whole record. */ + days: number | null measuredAt: string /** What one accepted issue and one bee-hour are worth, so the page can say so. */ scoring: { acceptedXp: number; hourXp: number } contributors: Contributor[] } -export async function leaderboard(pool: Pool, days = 30): Promise { +export async function leaderboard( + pool: Pool, + days: number | null = null, +): Promise { const work = await keyWork(pool, days) return { days, diff --git a/trios/agent-server/apps/server/tests/api/queen-leaderboard.test.ts b/trios/agent-server/apps/server/tests/api/queen-leaderboard.test.ts index 726df20f1c..a5ca3d9887 100644 --- a/trios/agent-server/apps/server/tests/api/queen-leaderboard.test.ts +++ b/trios/agent-server/apps/server/tests/api/queen-leaderboard.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from 'bun:test' +import type { Pool } from 'pg' + import { ACCEPTED_XP, githubLoginOf, HOUR_XP, type KeyWork, + keyWork, parseOwners, rank, xpFor, @@ -112,6 +115,44 @@ describe('the score', () => { expect(row.claimed).toBe(true) }) + /** + * The window is the one branch here that SQL decides rather than TypeScript, + * so it is pinned by what the query actually says. A fake pool is enough: + * the question is whether a time predicate is written and whether a parameter + * is passed, and both are visible without a database. + */ + describe('the window', () => { + const spy = () => { + const seen: { text: string; params: unknown[] } = { text: '', params: [] } + const pool = { + query: (text: string, params: unknown[]) => { + seen.text = text + seen.params = params + return Promise.resolve({ rows: [] }) + }, + } as unknown as Pool + return { pool, seen } + } + + it('asks for the whole record by default, with no time predicate at all', async () => { + const { pool, seen } = spy() + await keyWork(pool) + expect(seen.text).not.toContain('interval') + expect(seen.params).toEqual([]) + // The archive still keeps its own guard, which is not about time. + expect(seen.text).toContain("snapshot->>'key_index' ~ ") + }) + + it('narrows both halves when days is given, and passes it once', async () => { + const { pool, seen } = spy() + await keyWork(pool, 30) + expect(seen.params).toEqual([30]) + // Both the live table and the archive, or the answer is half-windowed. + expect(seen.text).toContain('dispatched_at > now()') + expect(seen.text).toContain('archived_at > now()') + }) + }) + it('gives an unclaimed lane no handle at all', () => { const [row] = rank([lane(7, 1, 1, 0)], {}) expect(row.name).toBe('key #7')