Isolate explicit client checks and submissions

Check named files over a detached HEAD worktree using the existing overrides pipeline. Restrict automatic submission task discovery to selected files, and leave dirty working trees in place during sync.
This commit is contained in:
Spl3en 2026-10-26 15:06:02 +01:00
commit 710cebf1c1
4 changed files with 62 additions and 28 deletions

View file

@ -20,7 +20,7 @@ Setup
[--toolchain <toolchain root>] compiler layout of the project (e.g. re/toolchain)
[--vc <VC dir>] [--sdk <SDK dir>] [--image <docker image>]
td toolchain install download the compiler (approved accounts) for local checks
td sync pull main and refresh the symbol database snapshot
td sync pull main (clean working tree only) and refresh the symbol snapshot
td ghidra download the server's Ghidra project (names applied, refreshed after merges)
td ghidra install | build | sync pinned Ghidra: install it, analyze locally, re-apply symbols
td update update td now (it also checks by itself once an hour)
@ -31,13 +31,13 @@ Work (a region: a source file or class, done as a whole)
td regions [--free] [--prefix P] regions with open functions: free ones first
td region claim <file> | --class <Name> claim a region (several agents may share one)
td region release <file> | --class <Name> give a region back
td check [files...] [--includers] compile + compare on this machine (--includers: every
unit that includes a changed header, as the merge does)
td check [files...] [--includers] compile + compare on this machine; named files over a clean HEAD
(--includers: every unit that includes a changed header)
td submit [--notes T] submit the region's work as one batch
td tasks [--state S] [--limit N] list claimable tasks
td claim <taskId> claim a function that came back, to fix it
td task <taskId> show a task's context
td submit [taskId...] [--notes T] submit everything you hold (or these tasks) as one batch
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 result <attemptId> show a verification result
td withdraw <attemptId> take back a function waiting for review, to fix it

View file

@ -108,11 +108,11 @@ const TOOLS = [
{
name: 'td_check',
description:
'Compile on this machine and compare every annotated function against the original bytes: status, score and byte-level diffs. Default: the changed files (and one unit per changed header). includers: true compiles every unit that includes a changed header, as the merge will; do that before submitting a header change. Needs td_toolchain_install once.',
'Compile on this machine and compare every annotated function against the original bytes: status, score and byte-level diffs. Named files are compiled over a clean copy of HEAD (other uncommitted edits, such as another agent\'s unfinished work, are left out); default: the working tree with its changed files (and one unit per changed header). includers: true compiles every unit that includes a changed header, as the merge will; do that before submitting a header change. Needs td_toolchain_install once.',
inputSchema: {
type: 'object',
properties: {
files: { type: 'array', items: { type: 'string' }, description: 'repo-relative files; default: all changed files' },
files: { type: 'array', items: { type: 'string' }, description: 'repo-relative files, checked over clean HEAD; default: all changed files in the working tree' },
includers: { type: 'boolean', description: 'also compile every unit including a changed header' },
},
},
@ -132,7 +132,7 @@ const TOOLS = [
properties: {
taskIds: { type: 'array', items: { type: 'integer' }, description: 'tasks to submit; default: every function you hold and rewrote' },
taskId: int('a single task (same as taskIds: [id])'),
files: { type: 'array', items: { type: 'string' } },
files: { type: 'array', items: { type: 'string' }, description: 'repo-relative files that make up the whole submission; only functions rewritten in them are submitted. Default: every changed file your tasks allow' },
notes: str('short notes for reviewers and later agents'),
confirm: { type: 'array', items: { type: 'string' }, description: 'symbol IDs of functions whose existing code on main you checked and want peer-reviewed unchanged (otherwise unchanged functions are not submitted)' },
},
@ -239,7 +239,7 @@ const TOOLS = [
},
{
name: 'td_sync',
description: 'Fast-forward the local clone to the latest main and refresh the symbol database snapshot.',
description: 'Fast-forward the local clone to the latest main and refresh the symbol database snapshot. With uncommitted files in the working tree it only refreshes symbols and says which files hold it back.',
inputSchema: { type: 'object', properties: {} },
run: async (ws) => ops.sync(ws),
},

View file

@ -133,14 +133,20 @@ export async function refreshSymbols(ws) {
return r.data;
}
/** Fast-forwards the clone to the server's main and refreshes the symbol snapshot. */
/**
* Fast-forwards the clone to the server's main and refreshes the symbol snapshot. Only a clean
* working tree moves: in a shared checkout, uncommitted files may be someone's unfinished work.
*/
export async function sync(ws) {
ws.git('fetch', '-q', 'origin');
let pulled = true;
try {
ws.git('merge', '-q', '--ff-only', '@{u}');
} catch {
pulled = false;
const changed = ws.changedFiles();
let pulled = !changed.length;
if (pulled) {
try {
ws.git('merge', '-q', '--ff-only', '@{u}');
} catch {
pulled = false;
}
}
const snap = await refreshSymbols(ws);
// A newer Ghidra project on the server is only announced (it is a large download).
@ -149,20 +155,26 @@ export async function sync(ws) {
const g = await api(ws).get('/api/ghidra-project');
if (g.sha256 !== ws.config.ghidraPackage) ghidra = `a newer Ghidra project is available (${(g.size / 1048576).toFixed(0)} MB): td ghidra download`;
} catch {}
return { head: ws.head(), fastForwarded: pulled, symbols: snap.symbols.length, ghidra, note: pulled ? null : 'could not fast-forward (local commits or conflicting changes); merge origin/main yourself' };
const note = changed.length
? `not fast-forwarded: ${changed.length} uncommitted files (${changed.slice(0, 5).join(', ')}); submit or set them aside first`
: 'could not fast-forward (local commits or conflicting changes); merge origin/main yourself';
return { head: ws.head(), fastForwarded: pulled, symbols: snap.symbols.length, ghidra, note: pulled ? null : note };
}
/**
* Compiles and compares locally with the same pipeline the server uses. `includers`: also every
* unit that includes a changed header (what the server compiles before it merges), so a header
* change is known to build everywhere before it is submitted.
* Named files are compiled over a clean copy of HEAD, so other edits in a shared checkout (another
* agent's unfinished work) are not part of the check. Without files: the working tree as it is.
*/
export async function check(ws, files, { wait = true, includers = false } = {}) {
const project = ws.project;
const targets = files?.length ? files : ws.changedFiles();
const explicit = !!files?.length;
const targets = explicit ? files.map((f) => f.replace(/\\/g, '/')) : ws.changedFiles();
const runner = ws.config.runners?.[project.toolchain];
if (!runner || runner.type === 'remote') {
if (ws.config.remoteChecks) return remoteCheck(ws, targets, { wait });
if (ws.config.remoteChecks) return remoteCheck(ws, targets, { wait, explicit });
throw new Error('no local compiler set up: run "td toolchain install" once (it downloads the compiler from the server, for approved accounts), then check again. The server only compiles submissions.');
}
const units = new Set(targets.filter(isUnit));
@ -176,7 +188,8 @@ export async function check(ws, files, { wait = true, includers = false } = {})
}
if (!units.size) throw new Error('nothing to check: pass .cpp files or modify some');
const result = await checkUnits({
repoDir: ws.dir,
repoDir: explicit ? headTree(ws) : ws.dir,
overrides: explicit ? readFiles(ws, targets) : {},
jobDir: path.join(ws.tdDir, 'build'),
project,
units: [...units],
@ -189,13 +202,28 @@ export async function check(ws, files, { wait = true, includers = false } = {})
return summarizeCheck(result, rec, new Set(targets));
}
/** A detached worktree at HEAD under .td/, updated on each use. */
function headTree(ws) {
const dir = path.join(ws.tdDir, 'head');
if (!fs.existsSync(path.join(dir, '.git'))) ws.git('worktree', 'add', '-q', '--detach', '-f', dir, 'HEAD');
// -f: the tree is only read; anything left in it is discarded.
execFileSync('git', ['checkout', '-q', '-f', '--detach', ws.head()], { cwd: dir, stdio: 'ignore' });
return dir;
}
function readFiles(ws, files) {
const out = {};
for (const f of files) out[f] = fs.readFileSync(path.join(ws.dir, f), 'utf8');
return out;
}
/**
* Compiles and compares on the server (its sandboxed compiler), for contributors without a
* local toolchain. Sends the changed files and the commit they are based on; nothing is submitted.
*/
async function remoteCheck(ws, targets, { wait = true } = {}) {
async function remoteCheck(ws, targets, { wait = true, explicit = false } = {}) {
const files = {};
for (const f of new Set([...targets, ...ws.changedFiles()])) {
for (const f of new Set(explicit ? targets : [...targets, ...ws.changedFiles()])) {
const abs = path.join(ws.dir, f);
if (fs.existsSync(abs) && /\.(c|cc|cpp|cxx|h|hpp|inl)$/i.test(f)) files[f.replace(/\\/g, '/')] = fs.readFileSync(abs, 'utf8');
}
@ -227,17 +255,17 @@ export async function original(ws, query, kind) {
return r.pseudocode ?? `// no pseudocode for ${r.symbol ?? query} yet (${r.source}); the assembly is available with td disasm`;
}
/** Submits files for a leased task. Defaults to all changed files the task allows. */
/**
* Submits work. `taskIds` empty = every task you hold, as one batch (the regions you claimed,
* plus functions you took back to fix). Defaults to all changed files those tasks allow.
* plus functions you took back to fix). Defaults to all changed files those tasks allow; named
* `files` are the whole submission, and only functions rewritten in them count as new work.
*/
export async function submit(ws, taskIds = [], { files, notes, agentMeta, confirm = [] } = {}) {
const me = await api(ws).get('/api/me');
let ids = taskIds.length ? taskIds.map(Number) : me.leases;
// Held functions whose code was not touched stay held; they are not new work.
if (!taskIds.length && ids.length) {
const rewritten = rewrittenFunctions(ws, me.leasedAllowedPaths ?? []);
const rewritten = rewrittenFunctions(ws, me.leasedAllowedPaths ?? [], files);
const symbols = me.leasedSymbols ?? [];
ids = symbols.filter((t) => rewritten.has(t.symbol) || confirm.includes(t.symbol)).map((t) => t.taskId);
}
@ -245,7 +273,7 @@ export async function submit(ws, taskIds = [], { files, notes, agentMeta, confir
// Shared regions: functions another agent holds are submitted as competing candidates, but only
// the ones this agent actually rewrote.
if (!taskIds.length && me.regionTasks?.length) {
const rewritten = rewrittenFunctions(ws, me.regionAllowedPaths ?? []);
const rewritten = rewrittenFunctions(ws, me.regionAllowedPaths ?? [], files);
const extra = me.regionTasks.filter((t) => rewritten.has(t.symbol)).map((t) => t.taskId);
if (extra.length) {
ids = [...new Set([...ids, ...extra])];
@ -274,10 +302,11 @@ export async function submit(ws, taskIds = [], { files, notes, agentMeta, confir
return api(ws).post('/api/batches', { taskIds: ids, baseCommit: ws.head(), files: contents, notes, agentMeta, envDigest: ws.config.envDigest, ...(confirm.length ? { confirm } : {}) });
}
/** Annotated functions whose definition differs from HEAD in the changed files under allowed paths. */
function rewrittenFunctions(ws, allowed) {
/** Annotated functions whose definition differs from HEAD in the changed (or named) files under allowed paths. */
function rewrittenFunctions(ws, allowed, files) {
const out = new Set();
for (const f of ws.changedFiles().filter((x) => matchesAny(x, allowed))) {
const changed = files?.length ? files.map((f) => f.replace(/\\/g, '/')) : ws.changedFiles();
for (const f of changed.filter((x) => matchesAny(x, allowed))) {
const abs = path.join(ws.dir, f);
if (!fs.existsSync(abs)) continue;
const text = fs.readFileSync(abs, 'utf8').replace(/\r\n/g, '\n');

View file

@ -52,7 +52,9 @@ region right away; do not wait for its result.
or three checks per function are enough; `exact` (or `match_unresolved`:
the bytes match, some referenced data has no symbol yet) is a bonus, not the
bar. After a header change, `td_check` with `includers: true` compiles every
unit that includes it, as the merge will.
unit that includes it, as the merge will. In a checkout shared with other
agents, name your files (`files: [...]`): they are compiled over a clean HEAD,
so another agent's unfinished edits are not part of your check.
6. **`td_submit`** the whole region as one batch. The server compiles it once:
- **byte-identical** functions merge **automatically**;
- **not byte-identical but compiling** goes to **peer review**: it merges once
@ -61,6 +63,9 @@ region right away; do not wait for its result.
- compile errors, regressions or wrong references come back to you, per
function; the rest of the batch goes on.
In a shared checkout pass the same `files` to `td_submit`: only those files,
and the functions rewritten in them, are submitted.
Found a mistake in a function that waits for review? `td_withdraw` it: it
leaves the queue and returns to your claim, to fix and submit again.
7. **If something breaks a neighbour** (your change, or someone else's, makes a