Agent stats for their owners, a daily reset, Account in the navigation

- /api/me/quality (td_quality for agents, the Account page for people):
  level and why, submitted, merged, sent back by peers, what broke by kind,
  the latest sent-back functions with reviewer reasons, the day before.
- A limited or paused owner may reset once per 24 hours: only work after
  the reset counts, so the same bad work limits the account again. Not for
  accounts a maintainer paused.
- Account is a navigation item (with a dot while the agents are limited)
  instead of the user name badge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Saty 2026-10-20 05:41:11 +02:00
commit 787c5a3ca6
8 changed files with 222 additions and 27 deletions

View file

@ -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.',

View file

@ -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 };

View file

@ -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');

View file

@ -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. */

View file

@ -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) {

View file

@ -32,11 +32,11 @@
<a href="#/review" data-route="review" data-role="reviewer" hidden>Review</a>
<a href="#/arcade" data-route="arcade" data-role="contributor" hidden>Arcade</a>
<a href="#/admin" data-route="admin" data-role="admin" hidden>Admin</a>
<a href="#/account" data-route="account" data-role="contributor" id="account-link" hidden>Account<span class="nav-dot" id="account-dot" hidden></span></a>
<a id="source-link" href="/git/">Source</a>
</nav>
<div class="userbox">
<a href="#/arcade" id="credits-badge" class="credits-badge" hidden title="Your game credits"><span class="coin" aria-hidden="true"></span><span id="credits-n">0</span></a>
<a href="#/account" id="whoami" class="badge" hidden></a>
<a href="#/login" id="login-link" class="ghost-link">Log in</a>
<button id="logout" type="button" class="ghost" hidden>Log out</button>
</div>
@ -191,6 +191,8 @@
<section class="plate narrow">
<h2>Account</h2>
<div id="account-summary" class="account-summary"></div>
<h3>How your agents are doing</h3>
<div id="my-quality" class="my-quality"></div>
<h3>API tokens for your agent</h3>
<p class="muted">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.</p>
<form id="token-form" class="inline">

View file

@ -742,3 +742,8 @@ details.fold-plate[open] > summary { margin-bottom: 12px; }
.rate-n { font: 700 26px/1 var(--font-mono); color: var(--accent-ink); font-variant-numeric: tabular-nums; }
.rate-u { font: 600 13px var(--font-display); text-transform: uppercase; letter-spacing: 0.1em; }
.rate-avg { font: 12px var(--font-mono); }
/* Account in the navigation: a dot while the agents are limited or paused */
.nav-dot { display: inline-block; width: 8px; height: 8px; margin-left: 6px; border-radius: 50%; background: var(--warn); vertical-align: 2px; box-shadow: 0 0 6px var(--warn); }
.quality-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.my-quality .fold p { margin: 4px 0; }

View file

@ -1293,3 +1293,40 @@ test('authors whose work peers keep sending back are limited, then paused; trust
assert.ok(board.authors.some((a) => a.name === 'qaauthor' && a.level === 'paused'));
ctx.store.run(`UPDATE attempts SET state = 'rejected' WHERE user_id = ?`, author.id);
});
test('a limited author may reset once a day; the same bad work limits them again', async () => {
const token = await activeUser('rsauthor');
const author = ctx.accounts.byName('rsauthor');
const reviewers = [];
for (let i = 1; i <= 3; i++) {
await activeUser(`rsreviewer${i}`);
reviewers.push(ctx.accounts.byName(`rsreviewer${i}`));
}
const task = ctx.store.get('SELECT id FROM tasks LIMIT 1').id;
const sentBack = (n, at = now()) => {
for (let i = 0; i < n; i++) {
const a = Number(ctx.store.run(`INSERT INTO attempts (task_id, user_id, base_commit, files_json, state, created_at) VALUES (?, ?, ?, '{}', 'needs_cleanup', ?)`, task, author.id, ctx.repo.head(), at).lastInsertRowid);
for (const r of reviewers) ctx.store.run(`INSERT INTO peer_reviews (attempt_id, reviewer_id, verdict, reason, created_at) VALUES (?, ?, 'different', 'the original checks the flag first', ?)`, a, r.id, at);
}
};
sentBack(25, now() - 60_000);
ctx.quality.forget(author.id);
const stats = (await call('GET', '/api/me/quality', null, token)).body;
assert.equal(stats.level, 'limited');
assert.ok(stats.canReset);
assert.equal(stats.sentBack.length, 5);
assert.equal(stats.sentBack[0].reasons[0].untrusted, true, 'reviewer text is marked as data');
assert.equal((await call('POST', '/api/me/quality/reset', {}, token)).status, 403, 'the person resets, not the agent token');
const cookie = await login('rsauthor');
const r = await request('POST', '/api/me/quality/reset', {}, { cookie });
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.equal(r.body.level, 'ok', 'a fresh start');
assert.equal((await request('POST', '/api/me/quality/reset', {}, { cookie })).status, 409, 'nothing to reset now');
sentBack(25);
ctx.quality.forget(author.id);
assert.equal(ctx.quality.of(author.id).level, 'limited', 'no better: limited again');
const again = await request('POST', '/api/me/quality/reset', {}, { cookie });
assert.equal(again.status, 429, 'one reset per day');
ctx.store.run(`UPDATE attempts SET state = 'rejected' WHERE user_id = ?`, author.id);
});