From b14140ba279cfeb7f55cf63be64b06e0c6648efa Mon Sep 17 00:00:00 2001 From: ragnarok-clanker Date: Mon, 26 Oct 2026 15:06:02 +0100 Subject: [PATCH] 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. --- src/client/cli.js | 8 +++--- src/client/mcp.js | 8 +++--- src/client/ops.js | 67 +++++++++++++++++++++++++++++++++------------- template/AGENTS.md | 7 ++++- 4 files changed, 62 insertions(+), 28 deletions(-) diff --git a/src/client/cli.js b/src/client/cli.js index b3965c7..26378a1 100644 --- a/src/client/cli.js +++ b/src/client/cli.js @@ -20,7 +20,7 @@ Setup [--toolchain ] compiler layout of the project (e.g. re/toolchain) [--vc ] [--sdk ] [--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 | --class claim a region (several agents may share one) td region release | --class 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 claim a function that came back, to fix it td task 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 state of a submitted batch, per function td result show a verification result td withdraw take back a function waiting for review, to fix it diff --git a/src/client/mcp.js b/src/client/mcp.js index 8db2f71..8dd51b5 100644 --- a/src/client/mcp.js +++ b/src/client/mcp.js @@ -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), }, diff --git a/src/client/ops.js b/src/client/ops.js index 9b6ad3c..78417a3 100644 --- a/src/client/ops.js +++ b/src/client/ops.js @@ -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'); diff --git a/template/AGENTS.md b/template/AGENTS.md index 36fe6b5..db85f50 100644 --- a/template/AGENTS.md +++ b/template/AGENTS.md @@ -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