diff --git a/src/server/app.js b/src/server/app.js index 3725db4..cc0f673 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -300,17 +300,42 @@ export function createApp(ctx) { }); // Work responses carry the project's current policy for agents (guidance.js). - const guided = (r) => withGuidance(store, r); + const guided = (r) => withGuidance(store, r, { peerReviewFirstAt: config.peerReviewFirstAt ?? 100 }); router.on('GET', '/api/tasks/:id', ({ req, params }) => { auth(req); return guided(tasks.context(intParam(params.id))); }); + // While the peer review queue is long, new work costs a few reviews first: verdicts are what + // keeps finished work moving to main. Work already held, and an agent's own returned functions, + // are not affected; neither is anyone with nothing they may review. + const reviewFirst = (user) => { + const at = config.peerReviewFirstAt ?? 100; + const waiting = store.get(`SELECT COUNT(*) AS n FROM attempts WHERE state = 'peer_review'`).n; + if (waiting <= at) return; + if (store.get('SELECT 1 FROM users WHERE id = ? AND peer_review_blocked = 1', user.id)) return; + if (!peers.available(user, 1).length) return; + const need = config.reviewsPerClaim ?? 3; + const lastClaim = store.get(`SELECT MAX(at) AS t FROM events WHERE user_id = ? AND type IN ('claim', 'claim_batch')`, user.id).t ?? 0; + const done = store.get('SELECT COUNT(*) AS n FROM peer_reviews WHERE reviewer_id = ? AND created_at > ?', user.id, lastClaim).n; + if (done >= need) return; + throw new HttpError( + 429, + `The peer review queue is long (${waiting} functions waiting). Do ${need - done} more peer review${need - done > 1 ? 's' : ''} first (td_claim_peer_review, then td_submit_peer_review for each function), then claim new work. Keep working on and submitting what you already hold.`, + ); + }; + router.on('POST', '/api/tasks/claim', async ({ req }) => { const user = auth(req); const body = await readJson(req, 4096); - if (body.taskId) return guided(tasks.context(tasks.claim(user, intParam(body.taskId)))); + if (body.taskId) { + // Taking back one's own returned function is not new work. + const own = store.get('SELECT 1 FROM tasks WHERE id = ? AND preferred_user = ? AND preferred_until > ?', intParam(body.taskId), user.id, Date.now()); + if (!own) reviewFirst(user); + return guided(tasks.context(tasks.claim(user, intParam(body.taskId)))); + } + reviewFirst(user); // "The next task" is the next group: one function at a time across classes wastes the // agent's context and the compile per submission. const ids = tasks.claimNext(user, 50); @@ -386,6 +411,7 @@ export function createApp(ctx) { router.on('POST', '/api/tasks/claim-batch', async ({ req }) => { const user = auth(req); const body = await readJson(req, 4096); + reviewFirst(user); const hexOrNull = (x) => (x == null ? null : typeof x === 'string' ? Number.parseInt(x, 16) : Number(x)); const ids = tasks.claimBatch(user, { unit: body.unit ? String(body.unit) : null, start: hexOrNull(body.start), end: hexOrNull(body.end), limit: body.limit ? Number(body.limit) : 50 }); const v = store.currentVersion(); diff --git a/src/server/guidance.js b/src/server/guidance.js index a1a0437..de50ae7 100644 --- a/src/server/guidance.js +++ b/src/server/guidance.js @@ -34,7 +34,12 @@ export function resetAgentGuidance(store) { } /** A response with the guidance first, so an agent reads it before the details. */ -export function withGuidance(store, response) { - const g = agentGuidance(store); +export function withGuidance(store, response, { peerReviewFirstAt = 100 } = {}) { + let g = agentGuidance(store); + // A long review queue holds finished work back from main: say so while it lasts. + const waiting = store.get(`SELECT COUNT(*) AS n FROM attempts WHERE state = 'peer_review'`).n; + if (g && waiting > peerReviewFirstAt) { + g += ` RIGHT NOW: ${waiting} functions wait for peer review. Before claiming new work, do peer reviews (td_list_peer_reviews / td_claim_peer_review); new claims need a few reviews first while the queue is this long.`; + } return g ? { guidance: g, ...response } : response; } diff --git a/test/server.test.js b/test/server.test.js index e76e919..4a8519b 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -580,6 +580,43 @@ test('the symbol table is only sent when it changed; downloads come compressed w await plain.arrayBuffer(); }); +test('a long review queue makes new claims wait for a few peer reviews', async () => { + await activeUser('rfauthor'); + const author = ctx.accounts.byName('rfauthor'); + const worker = await activeUser('rfworker'); + const v = ctx.store.currentVersion(); + const id = ctx.store.ensureSymbol({ kind: 'function', versionId: v.id, addr: 0x40f800, size: 8, unit: 'src/rf.cpp' }).symbol.id; + ctx.tasks.generate(); + const task = ctx.store.get('SELECT id FROM tasks WHERE symbol_id = ?', id).id; + ctx.store.run(`UPDATE tasks SET state = 'peer_review' WHERE id = ?`, task); + const attempt = Number(ctx.store.run(`INSERT INTO attempts (task_id, user_id, base_commit, files_json, state, created_at) VALUES (?, ?, ?, '{}', 'peer_review', ?)`, task, author.id, ctx.repo.head(), now()).lastInsertRowid); + const before = ctx.config.peerReviewFirstAt; + ctx.config.peerReviewFirstAt = 0; + try { + const refused = await call('POST', '/api/tasks/claim-batch', {}, worker); + assert.equal(refused.status, 429); + assert.match(refused.body.error, /peer review/); + const g = await call('GET', `/api/tasks/${task}`, null, worker); + assert.match(g.body.guidance, /wait for peer review/, 'the guidance says so too'); + // Reviews until new work is allowed: three verdicts, or nothing left this worker may review. + let ok = refused; + for (let i = 0; i < 4 && ok.status === 429; i++) { + const c = await call('POST', '/api/peer-reviews/claim', {}, worker); + for (const f of c.body.functions ?? []) { + await call('POST', `/api/peer-reviews/${f.attemptId}/verdict`, { verdict: 'same', reason: 'the same branches and calls as the original' }, worker); + } + ok = await call('POST', '/api/tasks/claim-batch', {}, worker); + } + assert.notEqual(ok.status, 429, JSON.stringify(ok.body)); + assert.ok(ctx.store.get('SELECT COUNT(*) AS n FROM peer_reviews WHERE reviewer_id = ?', ctx.accounts.byName('rfworker').id).n <= 3); + for (const t of ok.body.tasks ?? []) await call('POST', `/api/tasks/${t.task}/release`, {}, worker); + } finally { + ctx.config.peerReviewFirstAt = before; + ctx.store.run(`UPDATE attempts SET state = 'rejected' WHERE id = ?`, attempt); + ctx.store.run(`UPDATE tasks SET state = 'done' WHERE id = ?`, task); + } +}); + test('the crew view is public and shows no account details', async () => { const token = await activeUser('crewview'); await call('GET', '/api/tasks', null, token); // the agent checks in