diff --git a/src/client/mcp.js b/src/client/mcp.js index 34ffd0c..61e478a 100644 --- a/src/client/mcp.js +++ b/src/client/mcp.js @@ -32,6 +32,12 @@ const TOOLS = [ inputSchema: { type: 'object', properties: { kind: { type: 'string', enum: ['unit', 'class'] }, key: str('repo path or class name') }, required: ['key'] }, run: async (ws, a) => ops.releaseRegion(ws, { kind: a.kind ?? 'unit', key: a.key }), }, + { + name: 'td_quality', + description: 'How your work is doing (last 24 hours): whether the account is limited or paused and why, how many of your functions peers sent back and what broke, with the latest examples and the reviewers\' reasons. Read it when submissions are refused, and to see what to do better.', + inputSchema: { type: 'object', properties: {} }, + run: async (ws) => ops.api(ws).get('/api/me/quality'), + }, { name: 'td_toolchain_install', description: 'Download the compiler from the server and set it up for local checks (once per machine; needs wine on Linux). td_check compiles on this machine only.', diff --git a/src/server/app.js b/src/server/app.js index 525a23c..4ac5364 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -936,6 +936,17 @@ export function createApp(ctx) { return tasks.get(t.id); }); + // How the account's agents are doing: level, numbers, what broke, recent examples (quality.js). + router.on('GET', '/api/me/quality', ({ req }) => { + const user = auth(req); + return ctx.quality.details(user.id); + }); + // The owner's once-a-day fresh start (a person on the website, not the agent). + router.on('POST', '/api/me/quality/reset', ({ req }) => { + const user = auth(req, { session: true }); + return ctx.quality.reset(user.id); + }); + router.on('GET', '/api/admin/authors', ({ req }) => { auth(req, { role: 'admin', session: true }); return { authors: ctx.quality.authors(), limits: LIMITS }; diff --git a/src/server/db.js b/src/server/db.js index 38280e4..0c32161 100644 --- a/src/server/db.js +++ b/src/server/db.js @@ -301,6 +301,7 @@ export class Store { this.addColumn('batches', 'cost', 'INTEGER'); // units its verification compiles (queue order) this.addColumn('users', 'peer_review_blocked', 'INTEGER NOT NULL DEFAULT 0'); // no peer reviews from this account this.addColumn('users', 'submit_mode', "TEXT NOT NULL DEFAULT 'auto'"); // auto (quality.js) | trusted | paused + this.addColumn('users', 'quality_reset_at', 'INTEGER'); // the owner's daily fresh start (quality.js) // "No decompile" claims: an attempt that says a function needs no source (peer reviewed like code). this.addColumn('attempts', 'kind', "TEXT NOT NULL DEFAULT 'code'"); // code | skip this.addColumn('attempts', 'skip_category', 'TEXT'); diff --git a/src/server/quality.js b/src/server/quality.js index 6e68502..1120f92 100644 --- a/src/server/quality.js +++ b/src/server/quality.js @@ -6,23 +6,30 @@ // paused no submissions or region claims // // The level follows the last 24 hours, so it recovers by itself once the work gets better (or the -// bad stretch ages out). Admins can set an account to 'trusted' (never limited) or 'paused'. +// bad stretch ages out). A limited or paused user may reset once a day: only work after the reset +// counts, so an agent that got better is free at once, and one that did not is limited again when +// its new work fills the same thresholds. Admins can set an account to 'trusted' (never limited) +// or 'paused' (no reset). import { now } from './db.js'; +import { HttpError } from './http.js'; +import { untrusted } from './tasks.js'; const WINDOW_MS = 24 * 3600_000; +const RESET_EVERY_MS = 24 * 3600_000; const CACHE_MS = 2 * 60_000; -// Feedback summaries of submissions that broke something or were refused before compiling. -const BROKEN = [ - 'compilation failed', - 'your change breaks %', - 'rejected%', - 'declares functions that are defined nowhere%', - 'removes the annotation of%', - 'annotation errors', - 'base commit % is not part of main%', +// Feedback summaries of submissions that broke something or were refused, by kind. +const KINDS = [ + ['compile', 'did not compile', ['compilation failed']], + ['regression', 'broke functions that matched before', ['your change breaks %']], + ['comments', 'comments or strings that are not allowed', ['rejected: the submission adds comments%']], + ['gate', 'refused by the static gate', ['rejected by the static gate%', 'rejected: banned%']], + ['undefined', 'declared functions that exist nowhere', ['declares functions that are defined nowhere%']], + ['annotations', 'removed or broke annotations', ['removes the annotation of%', 'annotation errors']], + ['stale', 'not synced with main', ['base commit % is not part of main%']], ]; +const BROKEN = KINDS.flatMap(([, , patterns]) => patterns); export const LIMITS = { limited: { queuedBatches: 1, submitsPerHour: 6, regions: 1 }, @@ -43,53 +50,117 @@ export class Quality { return value; } - compute(userId) { - const since = now() - WINDOW_MS; - const mode = this.store.get('SELECT submit_mode FROM users WHERE id = ?', userId)?.submit_mode ?? 'auto'; + // Counts over [since, until). + numbers(userId, since, until = now() + 1) { const brokenSql = BROKEN.map(() => "json_extract(a.result_json, '$.feedback.summary') LIKE ?").join(' OR '); const r = this.store.get( `SELECT COUNT(*) AS submitted, SUM(CASE WHEN ${brokenSql} THEN 1 ELSE 0 END) AS broken, + SUM(a.state = 'merged') AS merged, + SUM(a.state = 'merged' AND json_extract(a.result_json, '$.feedback.target.status') IN ('exact', 'match_unresolved')) AS identical, SUM((SELECT COUNT(*) FROM peer_reviews p WHERE p.attempt_id = a.id AND p.verdict = 'different') >= 3) AS peerRejected, SUM((SELECT COUNT(*) FROM peer_reviews p WHERE p.attempt_id = a.id) >= 3) AS peerDecided FROM attempts a - WHERE a.user_id = ? AND a.created_at > ? AND a.kind = 'code' AND a.state NOT IN ('withdrawn', 'superseded', 'queued', 'verifying')`, + WHERE a.user_id = ? AND a.created_at >= ? AND a.created_at < ? AND a.kind = 'code' AND a.state NOT IN ('withdrawn', 'superseded', 'queued', 'verifying')`, ...BROKEN, userId, since, + until, ); const submitted = r?.submitted ?? 0; - const broken = r?.broken ?? 0; const decided = r?.peerDecided ?? 0; - const rejected = r?.peerRejected ?? 0; - const peerRate = decided ? rejected / decided : 0; - const brokenRate = submitted ? broken / submitted : 0; + return { + submitted, + broken: r?.broken ?? 0, + merged: r?.merged ?? 0, + identical: r?.identical ?? 0, + peerDecided: decided, + peerRejected: r?.peerRejected ?? 0, + peerRate: decided ? (r.peerRejected ?? 0) / decided : 0, + brokenRate: submitted ? (r.broken ?? 0) / submitted : 0, + }; + } + + compute(userId) { + const u = this.store.get('SELECT submit_mode, quality_reset_at FROM users WHERE id = ?', userId); + const mode = u?.submit_mode ?? 'auto'; + const resetAt = u?.quality_reset_at ?? null; + const since = Math.max(now() - WINDOW_MS, resetAt ?? 0); + const n = this.numbers(userId, since); const reasons = []; let level = 'ok'; const raise = (to, why) => { if (to === 'paused' || level === 'ok') level = to; reasons.push(why); }; - if (decided >= 30 && peerRate >= 0.7) raise('paused', `${Math.round(peerRate * 100)}% of your reviewed functions were sent back by peers (${rejected} of ${decided})`); - else if (decided >= 20 && peerRate >= 0.5) raise('limited', `${Math.round(peerRate * 100)}% of your reviewed functions were sent back by peers (${rejected} of ${decided})`); - if (submitted >= 80 && brokenRate >= 0.85) raise('paused', `${Math.round(brokenRate * 100)}% of your submitted functions did not compile, broke other functions or were refused (${broken} of ${submitted})`); - else if (submitted >= 50 && brokenRate >= 0.6) raise('limited', `${Math.round(brokenRate * 100)}% of your submitted functions did not compile, broke other functions or were refused (${broken} of ${submitted})`); + const peer = `${Math.round(n.peerRate * 100)}% of your reviewed functions were sent back by peers (${n.peerRejected} of ${n.peerDecided})`; + const broke = `${Math.round(n.brokenRate * 100)}% of your submitted functions did not compile, broke other functions or were refused (${n.broken} of ${n.submitted})`; + if (n.peerDecided >= 30 && n.peerRate >= 0.7) raise('paused', peer); + else if (n.peerDecided >= 20 && n.peerRate >= 0.5) raise('limited', peer); + if (n.submitted >= 80 && n.brokenRate >= 0.85) raise('paused', broke); + else if (n.submitted >= 50 && n.brokenRate >= 0.6) raise('limited', broke); if (mode === 'trusted') level = 'ok'; if (mode === 'paused') { level = 'paused'; reasons.unshift('a maintainer paused submissions from this account'); } - return { level, mode, reasons, submitted, broken, peerDecided: decided, peerRejected: rejected, peerRate, brokenRate, windowHours: 24 }; + const nextReset = resetAt ? resetAt + RESET_EVERY_MS : 0; + const canReset = level !== 'ok' && mode === 'auto' && now() >= nextReset; + return { level, mode, reasons, ...n, since, resetAt, canReset, nextResetAt: canReset || level === 'ok' ? null : nextReset || null, windowHours: 24 }; } forget(userId) { this.cache.delete(userId); } + /** A fresh start for a limited or paused author: only work after now counts. Once a day. */ + reset(userId) { + const q = this.compute(userId); + if (q.mode === 'paused') throw new HttpError(403, 'a maintainer paused this account; ask them'); + if (q.level === 'ok') throw new HttpError(409, 'nothing to reset: this account is not limited'); + if (!q.canReset) throw new HttpError(429, `one reset per 24 hours; the next is possible at ${new Date(q.nextResetAt).toISOString()}`, { retryAfterSeconds: Math.ceil((q.nextResetAt - now()) / 1000) }); + this.store.run('UPDATE users SET quality_reset_at = ? WHERE id = ?', now(), userId); + this.store.event('quality_reset', userId, { was: q.level, peerRate: q.peerRate, brokenRate: q.brokenRate }); + this.forget(userId); + return this.details(userId); + } + + /** Everything an author (or their agent) needs to see how the work is going and what to fix. */ + details(userId) { + const q = this.of(userId); + const previous = this.numbers(userId, q.since - WINDOW_MS, q.since); + const kinds = KINDS.map(([key, label, patterns]) => { + const n = this.store.get( + `SELECT COUNT(*) AS n FROM attempts a WHERE a.user_id = ? AND a.created_at >= ? AND a.kind = 'code' AND (${patterns.map(() => "json_extract(a.result_json, '$.feedback.summary') LIKE ?").join(' OR ')})`, + userId, q.since, ...patterns, + ).n; + return { kind: key, label, functions: n }; + }).filter((k) => k.functions); + const sentBack = this.store.all( + `SELECT a.id, t.symbol_id, s.name, a.created_at FROM attempts a JOIN tasks t ON t.id = a.task_id JOIN symbols s ON s.id = t.symbol_id + WHERE a.user_id = ? AND a.created_at >= ? AND (SELECT COUNT(*) FROM peer_reviews p WHERE p.attempt_id = a.id AND p.verdict = 'different') >= 3 + ORDER BY a.id DESC LIMIT 5`, + userId, q.since, + ).map((r) => ({ + attemptId: r.id, + symbol: r.symbol_id, + name: r.name, + reasons: this.store.all(`SELECT reason FROM peer_reviews WHERE attempt_id = ? AND verdict = 'different' ORDER BY id LIMIT 3`, r.id).map((x) => untrusted(x.reason)), + })); + const broken = this.store.all( + `SELECT a.id, t.symbol_id, json_extract(a.result_json, '$.feedback.summary') AS summary FROM attempts a JOIN tasks t ON t.id = a.task_id + WHERE a.user_id = ? AND a.created_at >= ? AND a.kind = 'code' AND (${BROKEN.map(() => "json_extract(a.result_json, '$.feedback.summary') LIKE ?").join(' OR ')}) + ORDER BY a.id DESC LIMIT 5`, + userId, q.since, ...BROKEN, + ).map((r) => ({ attemptId: r.id, symbol: r.symbol_id, summary: String(r.summary).slice(0, 300) })); + return { ...q, previous, kinds, sentBack, broken, ...(q.level !== 'ok' ? { note: Quality.advice(q) } : {}), thresholds: { limited: 'half sent back by peers (20+ reviewed) or 60% broken (50+ submitted)', paused: '70% sent back (30+ reviewed) or 85% broken (80+ submitted)' } }; + } + /** What an agent is told when its level limits it. */ static advice(q) { const what = q.level === 'paused' ? 'Submissions and region claims are paused for this account' : 'This account is limited to one batch waiting at a time, six submissions an hour and one region'; - return `${what}: ${q.reasons.join('; ')}. It lifts by itself as your recent work gets better (last 24 hours). Port each function faithfully from the disassembly, td_check it until it compiles and matches structurally, and submit only what you checked.`; + const reset = q.canReset ? ' The account owner can reset this once a day on the website (Account page).' : ''; + return `${what}: ${q.reasons.join('; ')}. It lifts by itself as your recent work gets better (last 24 hours).${reset} Port each function faithfully from the disassembly, td_check it until it compiles and matches structurally, and submit only what you checked.`; } /** Every author with work in the window, for the admin view. */ diff --git a/src/server/web/app.js b/src/server/web/app.js index bd9b402..1385d9a 100644 --- a/src/server/web/app.js +++ b/src/server/web/app.js @@ -181,12 +181,14 @@ async function refresh() { me = null; } for (const a of document.querySelectorAll('.mainnav a[data-role]')) a.hidden = !atLeast(a.dataset.role); - $('whoami').hidden = !me; $('logout').hidden = !me; $('login-link').hidden = !!me; $('credits-badge').hidden = !me; if (me) { - $('whoami').textContent = `${me.name} · ${me.role}`; + $('account-link').title = `${me.name} · ${me.role}`; + // A dot on Account while the agents are limited or paused: the stats there say why. + $('account-dot').hidden = !me.quality || me.quality.level === 'ok'; + $('account-dot').title = me.quality?.level === 'ok' ? '' : `your agents are ${me.quality?.level}`; api('/api/credits/me').then((w) => setCredits(w.balance, false)).catch(() => {}); } connectLive(true); @@ -679,6 +681,66 @@ async function loadAccount() { sum.append(tile(fmt(me.leases?.length ?? 0), 'functions held')); if (me.regions?.length) sum.append(tile(fmt(me.regions.length), `region${me.regions.length > 1 ? 's' : ''} claimed`)); renderTokens(tokens); + renderQuality(await api('/api/me/quality').catch(() => null)); +} + +// The account's own quality numbers: level, what broke, recent examples, the daily reset. +function renderQuality(q) { + const box = $('my-quality'); + box.replaceChildren(); + if (!q) return; + const pct = (x) => `${Math.round(x * 100)}%`; + const head = el('div', null, 'quality-head'); + head.append(el('span', q.level === 'ok' ? 'all good' : q.level, `status ${q.level === 'ok' ? 'active' : q.level === 'limited' ? 'pending' : 'revoked'}`)); + if (q.resetAt && q.resetAt > Date.now() - 24 * 3600_000) head.append(el('span', ` counting since your reset ${ago(q.resetAt)}`, 'hint')); + box.append(head); + if (q.reasons.length) box.append(el('p', q.reasons.join('; '), 'warn')); + const trend = (now_, before, better) => { + if (before === null) return ''; + const d = now_ - before; + if (Math.abs(d) < 0.02) return ' (same as the day before)'; + return (better ? d < 0 : d > 0) ? ` (${d < 0 ? 'down' : 'up'} from ${pct(before)}: better)` : ` (${d < 0 ? 'down' : 'up'} from ${pct(before)}: worse)`; + }; + const prev = q.previous ?? {}; + const rows = [ + ['Functions submitted', fmt(q.submitted)], + ['Merged', `${fmt(q.merged)} (${fmt(q.identical)} byte-identical)`], + ['Sent back by peers', q.peerDecided ? `${pct(q.peerRate)} of ${fmt(q.peerDecided)} reviewed${trend(q.peerRate, prev.peerDecided ? prev.peerRate : null, true)}` : 'none reviewed yet'], + ['Broken or refused', q.submitted ? `${pct(q.brokenRate)} of ${fmt(q.submitted)}${trend(q.brokenRate, prev.submitted ? prev.brokenRate : null, true)}` : '-'], + ]; + box.append(table(['Last 24 hours', ''], rows)); + if (q.kinds.length) box.append(el('p', `What broke: ${q.kinds.map((k) => `${fmt(k.functions)} ${k.label}`).join(' · ')}`, 'muted')); + if (q.sentBack.length) { + const d = el('details', null, 'fold'); + d.append(el('summary', `Latest functions peers sent back (${q.sentBack.length})`)); + for (const s of q.sentBack) { + d.append(el('p', `${s.symbol} ${s.name ?? ''}`, 'mono')); + for (const r of s.reasons) d.append(el('p', `· ${r?.text ?? ''}`, 'hint')); + } + box.append(d); + } + if (q.broken.length) { + const d = el('details', null, 'fold'); + d.append(el('summary', `Latest broken submissions (${q.broken.length})`), ...q.broken.map((b) => el('p', `${b.symbol}: ${b.summary}`, 'hint'))); + box.append(d); + } + box.append(el('p', `Limits: ${q.thresholds.limited} limits the account; ${q.thresholds.paused} pauses it. Both lift by themselves as the last 24 hours get better.`, 'hint')); + if (q.level !== 'ok') { + const row = el('div', null, 'actions'); + const reset = button('Reset (start counting from now)', async () => { + if (!confirm('Start counting from now? Only work after this counts. If your agent keeps doing the same, it is limited again once the new work fills the same thresholds. You get one reset per 24 hours.')) return; + try { + renderQuality(await post('/api/me/quality/reset')); + toast('Reset: counting from now. Fix what your agent does wrong before letting it go on.', { icon: '🔄' }); + } catch (e) { + toast(e.message); + } + }, 'primary'); + reset.disabled = !q.canReset; + row.append(reset); + if (!q.canReset) row.append(el('span', q.mode === 'paused' ? ' A maintainer paused this account; ask them.' : ` Next reset possible ${q.nextResetAt ? new Date(q.nextResetAt).toLocaleString() : 'later'}.`, 'hint')); + box.append(row); + } } function renderTokens(tokens) { diff --git a/src/server/web/index.html b/src/server/web/index.html index a2b901c..f23e530 100644 --- a/src/server/web/index.html +++ b/src/server/web/index.html @@ -32,11 +32,11 @@ Review Arcade Admin + Account Source
@@ -191,6 +191,8 @@Tokens never expire, so revoke any you no longer use. A token can claim and submit work, but it cannot manage accounts, create tokens, review or play.