Add td returned: list and watch your functions that came back, with why

This commit is contained in:
ragnarok-clanker 2026-10-28 18:43:15 +01:00
commit 2271b6e5b5
4 changed files with 131 additions and 1 deletions

View file

@ -39,6 +39,8 @@ Work (a region: a source file or class, done as a whole)
td task <taskId> show a task's context
td submit [taskId...] [--files a.cpp,b.h] submit everything you hold (or these tasks, or only these files)
td batch <batchId> state of a submitted batch, per function
td returned [--watch] [--json] your functions that came back (sent back, rejected, returned), per batch,
with why; --watch polls and prints new ones as they come back
td result <attemptId> show a verification result
td withdraw <attemptId> take back a function waiting for review, to fix it
td heartbeat [taskId] | td release <taskId> extend your leases (all without an id)
@ -79,6 +81,36 @@ function printCheck(r) {
for (const c of r.referenceConflicts) console.log(`conflict: ${c.message ?? c}`);
}
function printReturned(r) {
for (const b of r.batches) {
console.log(`\nbatch ${b.batchId}${b.returned ? `: ${b.returned}` : ''}`);
for (const f of b.functions) {
const score = f.score == null ? '-' : `${(f.score * 100).toFixed(1)}%`;
const held = f.taskState === 'leased' ? ` held by ${f.holder}` : ` task ${f.taskState}`;
console.log(` #${f.taskId} ${f.symbol} ${(f.name ?? '').padEnd(30)} ${f.state.padEnd(13)} ${score.padStart(6)} attempt ${f.attemptId}${held} ${f.unit ?? ''}`);
if (f.summary) console.log(` ${f.summary}`);
if (f.peerOutcome) console.log(` ${f.peerOutcome}`);
for (const v of f.peerDifferent) console.log(` different (${v.reviewer}): ${v.reason?.text ?? v.reason ?? ''}`);
for (const c of f.reviewerComments) console.log(` ${c.action}: ${c.comment?.text ?? ''}`);
}
}
console.log(`\n${r.functions} function(s) to fix in ${r.batches.length} batch(es); td claim <taskId>, fix, td check, td submit <taskIds> --files ...`);
}
async function watchReturned(client, json) {
const seen = new Set();
for (;;) {
const r = await client.get('/api/me/returned');
const fresh = r.batches.map((b) => ({ ...b, functions: b.functions.filter((f) => !seen.has(f.attemptId)) })).filter((b) => b.functions.length);
for (const b of r.batches) for (const f of b.functions) seen.add(f.attemptId);
if (fresh.length) {
const out = { functions: fresh.reduce((n, b) => n + b.functions.length, 0), batches: fresh };
json ? console.log(JSON.stringify(out)) : (console.log(`== ${new Date().toISOString()}`), printReturned(out));
}
await new Promise((done) => setTimeout(done, 60_000));
}
}
/** How an MCP client starts us: the td binary itself, or node + this script when run from source. */
async function launcher() {
const sea = await import('node:sea').catch(() => null);
@ -133,6 +165,7 @@ async function main() {
force: { type: 'boolean', default: false },
workspace: { type: 'string' },
help: { type: 'boolean', short: 'h' },
watch: { type: 'boolean', default: false },
},
});
const [cmd, ...args] = positionals;
@ -181,6 +214,9 @@ connect your AI with: td connect --workspace "${ws.dir}"`);
return print(args[0] ? await client.post(`/api/tasks/${args[0]}/heartbeat`) : await client.post('/api/tasks/heartbeat-all'));
case 'batch':
return print(await client.get(`/api/batches/${args[0]}`));
case 'returned':
if (values.watch) return watchReturned(client, values.json);
return values.json ? print(await client.get('/api/me/returned')) : printReturned(await client.get('/api/me/returned'));
case 'release':
return print(await client.post(`/api/tasks/${args[0]}/release`));
case 'withdraw':

View file

@ -208,6 +208,12 @@ const TOOLS = [
inputSchema: { type: 'object', properties: { batchId: int('batch id') }, required: ['batchId'] },
run: async (ws, a) => ops.api(ws).get(`/api/batches/${a.batchId}`),
},
{
name: 'td_returned',
description: 'Your functions that came back and still need work (sent back by peers or reviewers, rejected at a gate, returned with their batch), grouped by batch: task id, attempt id, why (summary, peer "different" reasons, reviewer comments) and whether the task is held. Fix them with td_claim_task, td_check and td_submit; read details with td_get_result.',
inputSchema: { type: 'object', properties: { since: int('only attempts finished at or after this time (ms since epoch)') } },
run: async (ws, a) => ops.api(ws).get(`/api/me/returned${a.since ? `?since=${a.since}` : ''}`),
},
{
name: 'td_get_result',
description: 'Get the verification result of a submitted attempt (state queued/verifying until done).',

View file

@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
import { Store, now } from './db.js';
import { Accounts, AccountError, hasRole, ROLES } from './accounts.js';
import { Repo, serveGit } from './git.js';
import { Tasks } from './tasks.js';
import { Tasks, untrusted } from './tasks.js';
import { Verifier } from './verify.js';
import { Merger } from './merge.js';
import { Maintenance } from './maintenance.js';
@ -638,6 +638,53 @@ export function createApp(ctx) {
});
});
// The caller's functions that came back and still need work: the latest attempt of each task,
// sent back, gate-rejected or returned with its batch, while the task is not done. Grouped by batch.
router.on('GET', '/api/me/returned', ({ req, query }) => {
const user = auth(req);
const since = query.get('since') ? intParam(query.get('since')) : 0;
const limit = Math.min(500, Math.max(1, intParam(query.get('limit') ?? '200')));
const rows = store.all(
`SELECT a.id, a.batch_id, a.task_id, a.state, a.score, a.finished_at, a.result_json, t.symbol_id, t.state AS task_state, t.cleanup_cycles,
s.name, s.unit, lu.name AS holder, b.result_json AS batch_result
FROM attempts a JOIN tasks t ON t.id = a.task_id JOIN symbols s ON s.id = t.symbol_id
LEFT JOIN users lu ON lu.id = t.lease_user AND t.state = 'leased'
LEFT JOIN batches b ON b.id = a.batch_id
WHERE a.user_id = ? AND a.state IN ('needs_cleanup', 'rejected', 'returned') AND COALESCE(a.finished_at, a.created_at) >= ?
AND t.state IN ('open', 'leased', 'needs_cleanup', 'hard')
AND NOT EXISTS (SELECT 1 FROM attempts n WHERE n.task_id = a.task_id AND n.id > a.id)
ORDER BY a.batch_id DESC, a.id LIMIT ?`,
user.id, since, limit,
);
const batches = new Map();
for (const r of rows) {
if (!batches.has(r.batch_id)) {
const br = r.batch_result ? JSON.parse(r.batch_result) : {};
batches.set(r.batch_id, { batchId: r.batch_id, returned: br.returned ?? null, functions: [] });
}
const res = r.result_json ? JSON.parse(r.result_json) : {};
const fb = res.feedback ?? {};
batches.get(r.batch_id).functions.push({
attemptId: r.id,
taskId: r.task_id,
symbol: r.symbol_id,
name: r.name,
unit: r.unit,
state: r.state,
score: r.score,
taskState: r.task_state,
holder: r.holder ?? null,
cleanupCycles: r.cleanup_cycles,
finishedAt: r.finished_at,
summary: fb.summary ?? res.returned ?? null,
peerOutcome: res.peerOutcome ?? null,
peerDifferent: peers.verdicts(r.id).filter((v) => v.verdict === 'different').map((v) => ({ reviewer: v.reviewer, reason: v.reason })),
reviewerComments: store.all(`SELECT r.action, r.comment FROM reviews r WHERE r.attempt_id = ? AND r.action != 'approve'`, r.id).map((c) => ({ action: c.action, comment: untrusted(c.comment) })),
});
}
return guided({ functions: rows.length, batches: [...batches.values()] });
});
router.on('POST', '/api/tasks/heartbeat-all', ({ req }) => {
const user = auth(req);
return { ...tasks.heartbeatAll(user), regions: ctx.regions.heartbeat(user) };

View file

@ -1938,6 +1938,47 @@ test('the batch list shows every batch, also those sent back before review, filt
ctx.store.run(`UPDATE attempts SET state = 'rejected' WHERE batch_id = ?`, sentBack);
});
test('returned lists the latest failed attempt of each unfinished task of the caller, per batch, with the reasons', async () => {
const token = await activeUser('rlister');
const me = ctx.accounts.byName('rlister');
await activeUser('rlother');
const other = ctx.accounts.byName('rlother');
const head = ctx.repo.head();
const task = (symbol) => {
ctx.store.run(`INSERT INTO symbols (id, kind, name, created_at) VALUES (?, 'function', ?, ?)`, symbol, symbol, now());
return { id: Number(ctx.store.run(`INSERT INTO tasks (symbol_id, state, updated_at) VALUES (?, 'open', ?)`, symbol, now()).lastInsertRowid), state: 'done' };
};
const [fixMe, fixedLater, done] = ['fn_rl_1', 'fn_rl_2', 'fn_rl_3'].map(task);
const batch = (user, result) => Number(ctx.store.run(`INSERT INTO batches (user_id, base_commit, files_json, state, result_json, created_at) VALUES (?, ?, '{}', 'returned', ?, ?)`, user.id, head, JSON.stringify(result), now()).lastInsertRowid);
const attempt = (user, task, state, batchId, feedback) =>
Number(ctx.store.run(`INSERT INTO attempts (task_id, user_id, base_commit, files_json, state, batch_id, result_json, created_at, finished_at) VALUES (?, ?, ?, '{}', ?, ?, ?, ?, ?)`, task.id, user.id, head, state, batchId, JSON.stringify({ feedback }), now(), now()).lastInsertRowid);
try {
const b = batch(me, { returned: '2 of 3 functions need work' });
const a = attempt(me, fixMe, 'needs_cleanup', b, { summary: 'peers sent it back' });
attempt(me, fixedLater, 'needs_cleanup', b, { summary: 'compilation failed' });
attempt(me, done, 'rejected', b, { summary: 'rejected by the static gate' });
attempt(other, fixedLater, 'peer_review', batch(other, {}), { summary: 'not byte-identical' });
ctx.store.run(`UPDATE tasks SET state = 'needs_cleanup' WHERE id = ?`, fixMe.id);
ctx.store.run(`UPDATE tasks SET state = 'done' WHERE id = ?`, done.id);
ctx.store.run(`INSERT INTO peer_reviews (attempt_id, reviewer_id, verdict, reason, created_at) VALUES (?, ?, 'different', 'the original checks the flag first', ?)`, a, other.id, now());
assert.equal((await call('GET', '/api/me/returned')).status, 401);
const r = (await call('GET', '/api/me/returned', null, token)).body;
assert.equal(r.functions, 1, 'a newer attempt and a done task are not work to fix');
assert.equal(r.batches.length, 1);
assert.equal(r.batches[0].batchId, b);
assert.equal(r.batches[0].returned, '2 of 3 functions need work');
const [f] = r.batches[0].functions;
assert.deepEqual([f.attemptId, f.taskId, f.state, f.taskState, f.summary], [a, fixMe.id, 'needs_cleanup', 'needs_cleanup', 'peers sent it back']);
assert.equal(f.peerDifferent[0].reason.untrusted, true);
assert.equal(f.peerDifferent[0].reason.text, 'the original checks the flag first');
assert.equal((await call('GET', `/api/me/returned?since=${now() + 60_000}`, null, token)).body.functions, 0);
assert.equal((await call('GET', '/api/me/returned', null, await activeUser('rlister2'))).body.functions, 0, 'only the caller\'s own attempts');
} finally {
for (const t of [fixMe, fixedLater, done]) ctx.store.run(`UPDATE tasks SET state = ? WHERE id = ?`, t.state, t.id);
}
});
test('review batch filtering precedes pagination and ignores stale open envelopes', async () => {
const token = await activeUser('batchfilter');
const user = ctx.accounts.byName('batchfilter');