Peer review needs three agreeing verdicts either way; public crew views; safe updates
Peer review: one "different" no longer sends a function back. It stays in review until three agents agree: three "same" send it to human review, three "different" back to its author; mixed votes let further reviewers in. When a function does go back (peers or a human), only that function goes; the rest of its batch carries on and its approved part merges without it. Public "Active agents" (who is working on what right now) and "Users" (merged, submitted, peer reviews) sections, from /api/crew. No token labels or account details. Updates: deploy/update.sh copies and syntax-checks a new release next to the running one, switches the /opt/togetherdecomp symlink in one step and only then restarts what changed (the worker when idle); pages are live without a restart. tools/deploy.sh ships HEAD (and with --td the td binaries). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNwtXPYqFu3oKaaXwYwNWW
This commit is contained in:
parent
2bcc26f82a
commit
cf2e111a6c
12 changed files with 334 additions and 59 deletions
|
|
@ -58,10 +58,15 @@ systemctl restart docker
|
|||
echo "== service user and code"
|
||||
id togetherdecomp >/dev/null 2>&1 || useradd --system --home-dir "$DATA" --shell /usr/sbin/nologin togetherdecomp
|
||||
usermod -aG docker togetherdecomp
|
||||
rm -rf "$APP.new" && mkdir -p "$APP.new"
|
||||
cp -a "$SRC/src" "$SRC/template" "$SRC/ghidra_scripts" "$SRC/toolchain" "$SRC/package.json" "$APP.new/"
|
||||
chown -R root:root "$APP.new" && chmod -R go-w "$APP.new"
|
||||
rm -rf "$APP.old"; [ -d "$APP" ] && mv "$APP" "$APP.old"; mv "$APP.new" "$APP"
|
||||
# Code updates go through update.sh (releases behind a symlink); here it only lays the code down.
|
||||
if [ ! -e "$APP" ]; then
|
||||
mkdir -p /opt/togetherdecomp-releases/initial
|
||||
cp -a "$SRC/src" "$SRC/template" "$SRC/ghidra_scripts" "$SRC/toolchain" "$SRC/deploy" "$SRC/package.json" /opt/togetherdecomp-releases/initial/
|
||||
chown -R root:root /opt/togetherdecomp-releases && chmod -R go-w,a+rX /opt/togetherdecomp-releases
|
||||
ln -s /opt/togetherdecomp-releases/initial "$APP"
|
||||
else
|
||||
sh "$SRC/deploy/update.sh" "$SRC"
|
||||
fi
|
||||
|
||||
mkdir -p "$DATA" && chown togetherdecomp:togetherdecomp "$DATA" && chmod 700 "$DATA"
|
||||
NODE="/opt/node/bin/node --disable-warning=ExperimentalWarning"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ ExecStart=/opt/node/bin/node --disable-warning=ExperimentalWarning /opt/together
|
|||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=NODE_ENV=production
|
||||
Environment=TD_WEB_DIR=/opt/togetherdecomp/src/server/web
|
||||
|
||||
# Hardening. The process needs: its data dir and git. Compiling happens in togetherdecomp-worker.
|
||||
NoNewPrivileges=true
|
||||
|
|
|
|||
85
deploy/update.sh
Normal file
85
deploy/update.sh
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
#!/bin/sh
|
||||
# Updates the coordinator's code without taking the site down. Run as root on the server:
|
||||
# sh deploy/update.sh <unpacked-source-dir>
|
||||
#
|
||||
# The new release is copied and syntax-checked next to the running one; nothing changes for the
|
||||
# running site until then. /opt/togetherdecomp is a symlink that switches to the new release in
|
||||
# one step. Pages (src/server/web) are read through it, so page changes are live right away with
|
||||
# no restart. Server code changes restart the web process (about a second) once everything is in
|
||||
# place, and the worker once it has no compile running. install.sh is for first installs and
|
||||
# system changes (packages, docker, caddy, firewall).
|
||||
set -eu
|
||||
|
||||
SRC=$(cd "${1:?usage: update.sh <source-dir>}" && pwd)
|
||||
BASE=/opt/togetherdecomp-releases
|
||||
LINK=/opt/togetherdecomp
|
||||
JOBS=/var/lib/togetherdecomp/jobs
|
||||
NODE=/opt/node/bin/node
|
||||
REL="$BASE/$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
mkdir -p "$BASE"
|
||||
# Earlier installs kept the code in a plain directory.
|
||||
if [ -d "$LINK" ] && [ ! -L "$LINK" ]; then
|
||||
mv "$LINK" "$BASE/initial"
|
||||
ln -s "$BASE/initial" "$LINK"
|
||||
fi
|
||||
OLD=$(readlink -f "$LINK" 2>/dev/null || true)
|
||||
|
||||
echo "== preparing $REL"
|
||||
mkdir -p "$REL"
|
||||
cp -a "$SRC/src" "$SRC/template" "$SRC/ghidra_scripts" "$SRC/toolchain" "$SRC/deploy" "$SRC/package.json" "$REL/"
|
||||
chown -R root:root "$REL"
|
||||
chmod -R go-w,a+rX "$REL"
|
||||
for f in $(find "$REL/src" -name '*.js'); do
|
||||
if ! "$NODE" --check "$f" 2>/dev/null; then
|
||||
"$NODE" --check "$f" || true
|
||||
rm -rf "$REL"
|
||||
echo "syntax error in ${f#$REL/}; the running release is unchanged" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
differs() { [ -z "$OLD" ] || ! diff -rq "$OLD/$1" "$REL/$1" >/dev/null 2>&1; }
|
||||
code=0
|
||||
for d in src/shared src/server; do
|
||||
if [ -n "$OLD" ] && diff -rq -x web "$OLD/$d" "$REL/$d" >/dev/null 2>&1; then :; else code=1; fi
|
||||
done
|
||||
units=0
|
||||
for u in togetherdecomp.service togetherdecomp-worker.service; do
|
||||
cmp -s "$REL/deploy/$u" "/etc/systemd/system/$u" || units=1
|
||||
done
|
||||
|
||||
echo "== switching to the new release"
|
||||
ln -sfn "$REL" "$LINK.next"
|
||||
mv -T "$LINK.next" "$LINK"
|
||||
|
||||
if [ "$units" = 1 ]; then
|
||||
install -m 0644 "$REL/deploy/togetherdecomp.service" "$REL/deploy/togetherdecomp-worker.service" /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
code=1
|
||||
fi
|
||||
|
||||
if [ "$code" = 0 ]; then
|
||||
differs src/server/web && echo "pages updated (live, no restart)" || echo "no server code changed"
|
||||
else
|
||||
echo "== restarting the website"
|
||||
systemctl restart togetherdecomp.service
|
||||
sleep 2
|
||||
systemctl is-active --quiet togetherdecomp.service || { journalctl -u togetherdecomp -n 20 --no-pager; exit 1; }
|
||||
# A restart re-queues the worker's running verification or merge; wait (up to 15 min) for it to finish.
|
||||
echo "== restarting the worker when it is idle"
|
||||
waited=0
|
||||
while [ -n "$(ls -A "$JOBS" 2>/dev/null)" ] && [ "$waited" -lt 900 ]; do
|
||||
sleep 5
|
||||
waited=$((waited + 5))
|
||||
done
|
||||
systemctl restart togetherdecomp-worker.service
|
||||
sleep 2
|
||||
systemctl is-active --quiet togetherdecomp-worker.service || { journalctl -u togetherdecomp-worker -n 20 --no-pager; exit 1; }
|
||||
fi
|
||||
|
||||
# Keep the last three releases for a quick rollback (ln -sfn <release> /opt/togetherdecomp).
|
||||
ls -1dt "$BASE"/*/ | tail -n +4 | while read -r old; do
|
||||
[ "$(readlink -f "$old")" = "$(readlink -f "$LINK")" ] || rm -rf "$old"
|
||||
done
|
||||
echo "== done: $(readlink -f "$LINK")"
|
||||
|
|
@ -23,7 +23,8 @@ import { loadProject, loadToolchainLock } from '../shared/project.js';
|
|||
import { DEFAULT_LIMITS, lintSubmission } from '../shared/lint.js';
|
||||
import { hex } from '../shared/annotations.js';
|
||||
|
||||
const WEB_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'web');
|
||||
// TD_WEB_DIR points through the release symlink in production, so new pages are live without a restart.
|
||||
const WEB_DIR = process.env.TD_WEB_DIR ?? path.join(path.dirname(fileURLToPath(import.meta.url)), 'web');
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
listen: { host: '127.0.0.1', port: 8080 },
|
||||
|
|
@ -182,6 +183,39 @@ export function createApp(ctx) {
|
|||
return { version: v.id, head: repo.head(), columns: ['id', 'kind', 'addr', 'size', 'name', 'decorated', 'library', 'unit'], symbols: rows };
|
||||
});
|
||||
|
||||
// Public: who is on the project and what their agents are doing right now. No token labels or
|
||||
// other account details; only names, roles and work.
|
||||
const ACTIVE_MS = 15 * 60_000;
|
||||
router.on('GET', '/api/crew', () => {
|
||||
const since = Date.now() - ACTIVE_MS;
|
||||
const users = store.all(
|
||||
`SELECT u.name, u.role, u.approved_at AS joined,
|
||||
(SELECT COUNT(*) FROM attempts a WHERE a.user_id = u.id AND a.state = 'merged') AS merged,
|
||||
(SELECT COUNT(*) FROM attempts a WHERE a.user_id = u.id) AS submitted,
|
||||
(SELECT COUNT(*) FROM peer_reviews p WHERE p.reviewer_id = u.id) AS peer_reviews,
|
||||
(SELECT MAX(last_used_at) FROM api_tokens t WHERE t.user_id = u.id) AS last_seen
|
||||
FROM users u WHERE u.status = 'active' ORDER BY merged DESC, peer_reviews DESC, u.approved_at`,
|
||||
);
|
||||
const agents = store.all(
|
||||
`SELECT u.id, u.name, COUNT(DISTINCT t.id) AS agents, MAX(t.last_used_at) AS last_seen
|
||||
FROM api_tokens t JOIN users u ON u.id = t.user_id
|
||||
WHERE t.revoked_at IS NULL AND t.last_used_at > ? AND u.status = 'active' GROUP BY u.id ORDER BY last_seen DESC`,
|
||||
since,
|
||||
);
|
||||
const active = agents.map((a) => {
|
||||
const units = store.all(
|
||||
`SELECT s.unit, COUNT(*) AS functions FROM tasks t JOIN symbols s ON s.id = t.symbol_id
|
||||
WHERE t.state = 'leased' AND t.lease_user = ? GROUP BY s.unit ORDER BY functions DESC`,
|
||||
a.id,
|
||||
);
|
||||
const reviewing = store.get('SELECT COUNT(DISTINCT attempt_id) AS n FROM peer_leases WHERE reviewer_id = ? AND expires_at > ?', a.id, Date.now()).n;
|
||||
const waiting = store.get(`SELECT COUNT(*) AS n FROM attempts WHERE user_id = ? AND state IN ('queued', 'verifying')`, a.id).n;
|
||||
const today = store.get(`SELECT COUNT(*) AS n FROM attempts WHERE user_id = ? AND created_at > ?`, a.id, Date.now() - 24 * 3600_000).n;
|
||||
return { name: a.name, agents: a.agents, lastSeen: a.last_seen, units: units.map((u) => ({ unit: u.unit ?? '(no unit)', functions: u.functions })), reviewing, verifying: waiting, submittedToday: today };
|
||||
});
|
||||
return { activeMinutes: ACTIVE_MS / 60_000, users, active };
|
||||
});
|
||||
|
||||
router.on('GET', '/api/progress', () => {
|
||||
const total = store.get(`SELECT COUNT(*) AS n, COALESCE(SUM(a.size), 0) AS bytes FROM symbols s JOIN symbol_addresses a ON a.symbol_id = s.id JOIN versions v ON v.id = a.version_id AND v.is_current = 1 WHERE s.kind = 'function' AND s.library = 0`);
|
||||
const byStatus = store.all(`SELECT fs.status, COUNT(*) AS n, COALESCE(SUM(a.size), 0) AS bytes FROM func_status fs JOIN symbol_addresses a ON a.symbol_id = fs.symbol_id JOIN versions v ON v.id = a.version_id AND v.is_current = 1 GROUP BY fs.status`);
|
||||
|
|
@ -518,6 +552,7 @@ export function createApp(ctx) {
|
|||
(SELECT COUNT(*) FROM reviews r WHERE r.attempt_id = a.id AND r.action = 'approve') AS approvals,
|
||||
(SELECT COUNT(*) FROM reviews r WHERE r.attempt_id = a.id AND r.reviewer_id = ?) AS reviewed_by_me,
|
||||
(SELECT COUNT(*) FROM peer_reviews p WHERE p.attempt_id = a.id AND p.verdict = 'same') AS peer_same,
|
||||
(SELECT COUNT(*) FROM peer_reviews p WHERE p.attempt_id = a.id AND p.verdict = 'different') AS peer_different,
|
||||
(SELECT COUNT(*) FROM peer_leases l WHERE l.attempt_id = a.id AND l.expires_at > ?) AS peer_working
|
||||
FROM attempts a JOIN users u ON u.id = a.user_id JOIN tasks t ON t.id = a.task_id JOIN symbols s ON s.id = t.symbol_id
|
||||
WHERE a.state IN ('in_review', 'peer_review') ORDER BY a.state = 'peer_review', a.finished_at`,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// A batch merges as a whole. When one of its attempts fails (verification, peer review, human
|
||||
// review or the merge re-check), the whole batch goes back to its author: every other attempt
|
||||
// that was still on its way is marked returned, and its task becomes claimable again (reserved
|
||||
// for the author for a while). Peer verdicts stay attached to their attempts, so a resubmission
|
||||
// whose compiled code is unchanged for a function carries them over (Verifier.carryOver).
|
||||
// When verification or the merge re-check fails, the whole batch goes back to its author: every
|
||||
// attempt that was still on its way is marked returned, and its task becomes claimable again
|
||||
// (reserved for the author for a while). Peer verdicts stay attached to their attempts, so a
|
||||
// resubmission whose compiled code is unchanged for a function carries them over
|
||||
// (Verifier.carryOver). A peer or human verdict against one function sends back only that
|
||||
// function; the rest of the batch goes on, and its approved part merges without it (merge.js).
|
||||
|
||||
import { now } from './db.js';
|
||||
|
||||
|
|
@ -23,6 +24,17 @@ export function returnBatch(store, batchId, reason) {
|
|||
store.event('batch_returned', b.user_id, { batch: batchId, reason });
|
||||
}
|
||||
|
||||
// Attempts still on their way to main; the others were sent back or rejected.
|
||||
export const LIVE_STATES = ['queued', 'verifying', 'peer_review', 'in_review', 'approved', 'merging'];
|
||||
|
||||
/** Ends a batch whose functions were all sent back or rejected. */
|
||||
export function settleBatch(store, batchId, reason) {
|
||||
if (!batchId) return;
|
||||
const ph = LIVE_STATES.map(() => '?').join(',');
|
||||
if (store.get(`SELECT 1 FROM attempts WHERE batch_id = ? AND state IN (${ph})`, batchId, ...LIVE_STATES)) return;
|
||||
store.run(`UPDATE batches SET state = 'returned', result_json = json_set(COALESCE(result_json, '{}'), '$.returned', ?) WHERE id = ? AND state = 'open'`, reason, batchId);
|
||||
}
|
||||
|
||||
/** Attempts of a batch with their task's symbol. */
|
||||
export function batchAttempts(store, batchId) {
|
||||
return store.all(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { HttpError } from './http.js';
|
|||
import { now } from './db.js';
|
||||
import { hasRole } from './accounts.js';
|
||||
import { STATUS } from '../shared/compare.js';
|
||||
import { returnBatch, batchAttempts } from './batches.js';
|
||||
import { returnBatch, batchAttempts, settleBatch } from './batches.js';
|
||||
import { withoutFunctions } from './split.js';
|
||||
|
||||
export class Merger {
|
||||
|
|
@ -79,7 +79,8 @@ export class Merger {
|
|||
const newState = action === 'reject' ? 'rejected' : 'needs_cleanup';
|
||||
this.store.run('UPDATE attempts SET state = ? WHERE id = ?', newState, attemptId);
|
||||
this.store.run(`UPDATE tasks SET state = 'needs_cleanup', preferred_user = ?, preferred_until = ?, updated_at = ? WHERE id = ?`, attempt.user_id, now() + 6 * 3600_000, now(), attempt.task_id);
|
||||
returnBatch(this.store, attempt.batch_id, `a reviewer asked for changes to ${attempt.task_id}`);
|
||||
// Only this function goes back; the rest of its batch carries on.
|
||||
settleBatch(this.store, attempt.batch_id, `a reviewer asked for changes to ${attempt.task_id}`);
|
||||
return { state: newState };
|
||||
}
|
||||
|
||||
|
|
@ -101,7 +102,7 @@ export class Merger {
|
|||
: this.store.get(
|
||||
`SELECT b.* FROM batches b WHERE b.state = 'open' AND json_extract(COALESCE(b.result_json, '{}'), '$.noSplit') IS NULL
|
||||
AND EXISTS (SELECT 1 FROM attempts a WHERE a.batch_id = b.id AND a.state = 'approved')
|
||||
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.batch_id = b.id AND a.state NOT IN ('approved', 'peer_review', 'in_review'))
|
||||
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.batch_id = b.id AND a.state IN ('queued', 'verifying', 'merging'))
|
||||
ORDER BY b.id LIMIT 1`,
|
||||
);
|
||||
const batch = whole ?? partial;
|
||||
|
|
@ -122,7 +123,10 @@ export class Merger {
|
|||
}
|
||||
}
|
||||
|
||||
/** Moves the approved attempts of `parent` into a batch of their own and merges that. */
|
||||
/**
|
||||
* Moves the approved attempts of `parent` into a batch of their own and merges that. Functions
|
||||
* still in review, and ones that were sent back, are left out of it.
|
||||
*/
|
||||
async mergeApprovedPart(parent) {
|
||||
const attempts = batchAttempts(this.store, parent.id);
|
||||
const approved = attempts.filter((a) => a.state === 'approved');
|
||||
|
|
@ -165,6 +169,11 @@ export class Merger {
|
|||
|
||||
noSplit(batch, reason) {
|
||||
this.store.run(`UPDATE batches SET result_json = json_set(COALESCE(result_json, '{}'), '$.noSplit', ?) WHERE id = ?`, reason, batch.id);
|
||||
// With a function sent back it can never merge as a whole, so it all goes back.
|
||||
if (this.store.get(`SELECT 1 FROM attempts WHERE batch_id = ? AND state IN ('needs_cleanup', 'rejected', 'returned')`, batch.id)) {
|
||||
returnBatch(this.store, batch.id, `a function of this batch was sent back and the rest cannot be merged without it: ${reason}`);
|
||||
return this.log.info(`batch ${batch.id} returned: ${reason}`);
|
||||
}
|
||||
this.log.info(`batch ${batch.id} waits to merge as a whole: ${reason}`);
|
||||
}
|
||||
|
||||
|
|
@ -233,7 +242,10 @@ export class Merger {
|
|||
}
|
||||
this.store.run(`UPDATE batches SET state = 'merged', merged_commit = ? WHERE id = ?`, commit, batch.id);
|
||||
this.store.event('merged', batch.user_id, { batch: batch.id, functions: attempts.length, commit });
|
||||
if (split) this.rebaseRemainder(split, merged.files, commit);
|
||||
if (split) {
|
||||
this.rebaseRemainder(split, merged.files, commit);
|
||||
settleBatch(this.store, split.parent.id, 'its approved functions merged; the others were sent back');
|
||||
}
|
||||
});
|
||||
this.tasks.generate();
|
||||
if (this.config.pushRemote) {
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@
|
|||
// A reviewer reads the original function (assembly and pseudocode, from their own pinned Ghidra
|
||||
// via `td disasm` / `td decompile`) and the submitted code, and decides whether the logic is the
|
||||
// same. No automated verdicts: the context deliberately leaves out the server's byte diff and score.
|
||||
// `peerReviews` (default 3) different accounts must all say "same"; one "different" sends the
|
||||
// attempt back to its author with the reason. Then it goes to human review.
|
||||
// An attempt stays in peer review until `peerReviews` (default 3) different accounts agree: that
|
||||
// many "same" verdicts send it on to human review, that many "different" ones send it back to its
|
||||
// author with their reasons. With mixed verdicts, further reviewers are let in until one side
|
||||
// gets there.
|
||||
|
||||
import { HttpError } from './http.js';
|
||||
import { now } from './db.js';
|
||||
import { untrusted } from './tasks.js';
|
||||
import { hex } from '../shared/annotations.js';
|
||||
import { returnBatch } from './batches.js';
|
||||
import { settleBatch } from './batches.js';
|
||||
|
||||
export const PEER_INSTRUCTIONS = [
|
||||
'Decide whether the submitted C++ does the same thing as the original function. It is not byte-identical; your judgment is what counts.',
|
||||
|
|
@ -39,6 +41,7 @@ export class PeerReviews {
|
|||
return this.store.all(
|
||||
`SELECT a.id AS attempt_id, a.batch_id, a.task_id, t.symbol_id, s.name, a.finished_at,
|
||||
(SELECT COUNT(*) FROM peer_reviews p WHERE p.attempt_id = a.id AND p.verdict = 'same') AS confirmations,
|
||||
(SELECT COUNT(*) FROM peer_reviews p WHERE p.attempt_id = a.id AND p.verdict = 'different') AS objections,
|
||||
(SELECT COUNT(*) FROM peer_leases l WHERE l.attempt_id = a.id AND l.expires_at > ?) AS in_progress
|
||||
FROM attempts a JOIN tasks t ON t.id = a.task_id JOIN symbols s ON s.id = t.symbol_id
|
||||
WHERE a.state = 'peer_review' AND a.user_id != ?
|
||||
|
|
@ -49,7 +52,7 @@ export class PeerReviews {
|
|||
user.id,
|
||||
user.id,
|
||||
Math.min(limit, 200),
|
||||
).filter((r) => r.confirmations + r.in_progress < this.required);
|
||||
).filter((r) => Math.max(r.confirmations, r.objections) + r.in_progress < this.required);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -149,9 +152,9 @@ export class PeerReviews {
|
|||
if (a.user_id === user.id) throw new HttpError(403, 'you cannot review your own attempt');
|
||||
if (this.store.get('SELECT 1 FROM peer_reviews WHERE attempt_id = ? AND reviewer_id = ?', attemptId, user.id)) throw new HttpError(409, 'you already reviewed this attempt');
|
||||
const leased = this.store.get('SELECT 1 FROM peer_leases WHERE attempt_id = ? AND reviewer_id = ? AND expires_at > ?', attemptId, user.id, now());
|
||||
const taken = this.store.get(`SELECT COUNT(*) AS n FROM peer_reviews WHERE attempt_id = ? AND verdict = 'same'`, attemptId).n;
|
||||
const counts = this.counts(attemptId);
|
||||
const others = this.store.get('SELECT COUNT(*) AS n FROM peer_leases WHERE attempt_id = ? AND reviewer_id != ? AND expires_at > ?', attemptId, user.id, now()).n;
|
||||
if (!leased && taken + others >= this.required) throw new HttpError(409, 'enough reviewers are already on this attempt; claim another');
|
||||
if (!leased && Math.max(counts.same, counts.different) + others >= this.required) throw new HttpError(409, 'enough reviewers are already on this attempt; claim another');
|
||||
|
||||
this.store.run(
|
||||
'INSERT INTO peer_reviews (attempt_id, reviewer_id, verdict, reason, agent_meta, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
|
|
@ -160,25 +163,28 @@ export class PeerReviews {
|
|||
this.store.run('DELETE FROM peer_leases WHERE attempt_id = ? AND reviewer_id = ?', attemptId, user.id);
|
||||
this.store.event('peer_review', user.id, { attempt: attemptId, verdict });
|
||||
|
||||
if (verdict === 'different') {
|
||||
// One "different" ends it: the author gets the reason, the other leases are dropped.
|
||||
const same = counts.same + (verdict === 'same' ? 1 : 0);
|
||||
const different = counts.different + (verdict === 'different' ? 1 : 0);
|
||||
if (different >= this.required) {
|
||||
// Enough independent reviewers found a difference: back to the author with their reasons.
|
||||
this.store.run('DELETE FROM peer_leases WHERE attempt_id = ?', attemptId);
|
||||
this.store.run(`UPDATE attempts SET state = 'needs_cleanup' WHERE id = ?`, attemptId);
|
||||
this.store.run(
|
||||
`UPDATE tasks SET state = 'needs_cleanup', cleanup_cycles = cleanup_cycles + 1, preferred_user = ?, preferred_until = ?, updated_at = ? WHERE id = ?`,
|
||||
a.user_id, now() + 6 * 3600_000, now(), a.task_id,
|
||||
);
|
||||
// The batch merges as a whole, so it goes back with this function; confirmed ones keep their verdicts.
|
||||
returnBatch(this.store, a.batch_id, `a peer review found a difference in ${a.task_id}`);
|
||||
return { state: 'needs_cleanup', confirmations: taken };
|
||||
// Only this function goes back; the rest of its batch carries on.
|
||||
settleBatch(this.store, a.batch_id, `${different} peer reviews found a difference in ${a.task_id}`);
|
||||
return { state: 'needs_cleanup', same, different, required: this.required };
|
||||
}
|
||||
const confirmations = taken + 1;
|
||||
if (confirmations >= this.required) {
|
||||
if (same >= this.required) {
|
||||
this.store.run('DELETE FROM peer_leases WHERE attempt_id = ?', attemptId);
|
||||
this.store.run(`UPDATE attempts SET state = 'in_review' WHERE id = ?`, attemptId);
|
||||
this.store.run(`UPDATE tasks SET state = 'in_review', updated_at = ? WHERE id = ?`, now(), a.task_id);
|
||||
return { state: 'in_review', confirmations };
|
||||
return { state: 'in_review', same, different, required: this.required };
|
||||
}
|
||||
return { state: 'peer_review', confirmations, required: this.required };
|
||||
// Undecided: it stays in review until one side has enough verdicts.
|
||||
return { state: 'peer_review', same, different, required: this.required };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -191,11 +197,12 @@ export class PeerReviews {
|
|||
}
|
||||
|
||||
/** True when the attempt passed peer review (enough "same", no "different"). */
|
||||
counts(attemptId) {
|
||||
const r = this.store.get(`SELECT SUM(verdict = 'same') AS same, SUM(verdict = 'different') AS different FROM peer_reviews WHERE attempt_id = ?`, attemptId);
|
||||
return { same: r?.same ?? 0, different: r?.different ?? 0 };
|
||||
}
|
||||
|
||||
confirmed(attemptId) {
|
||||
const r = this.store.get(
|
||||
`SELECT SUM(verdict = 'same') AS same, SUM(verdict = 'different') AS diff FROM peer_reviews WHERE attempt_id = ?`,
|
||||
attemptId,
|
||||
);
|
||||
return (r?.same ?? 0) >= this.required && !(r?.diff ?? 0);
|
||||
return this.counts(attemptId).same >= this.required;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -299,6 +299,46 @@ function claimedWork(w) {
|
|||
return [d, ...(seen ? [seen] : [])];
|
||||
}
|
||||
|
||||
// --- public crew views
|
||||
|
||||
const ago = (t) => {
|
||||
if (!t) return 'never';
|
||||
const m = Math.round((Date.now() - t) / 60_000);
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m} min ago`;
|
||||
const h = Math.round(m / 60);
|
||||
return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`;
|
||||
};
|
||||
|
||||
async function loadCrew() {
|
||||
const c = await api('/api/crew');
|
||||
const active = $('active-body');
|
||||
active.replaceChildren();
|
||||
active.classList.remove('muted');
|
||||
if (!c.active.length) {
|
||||
active.append(el('p', `No agent has checked in during the last ${c.activeMinutes} minutes.`, 'muted'));
|
||||
} else {
|
||||
active.append(
|
||||
table(
|
||||
['User', 'Working on', 'Peer reviews', 'Being checked', 'Submitted (24 h)', 'Last seen'],
|
||||
c.active.map((a) => {
|
||||
const work = a.units.length
|
||||
? a.units.map((u) => el('div', `${u.unit} · ${u.functions} function${u.functions > 1 ? 's' : ''}`))
|
||||
: [el('span', a.reviewing ? 'reviewing' : 'between tasks', 'muted')];
|
||||
return [`${a.name}${a.agents > 1 ? ` (${a.agents} agents)` : ''}`, work, a.reviewing || '', a.verifying || '', a.submittedToday, ago(a.lastSeen)];
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
$('users-count').textContent = `${c.users.length} approved`;
|
||||
$('users-body').replaceChildren(
|
||||
table(
|
||||
['User', 'Role', 'Merged', 'Submitted', 'Peer reviews', 'Joined', 'Agent last seen'],
|
||||
c.users.map((u) => [u.name, u.role, u.merged, u.submitted, u.peer_reviews, u.joined ? new Date(u.joined).toLocaleDateString() : '', ago(u.last_seen)]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- review
|
||||
|
||||
async function loadQueue() {
|
||||
|
|
@ -319,7 +359,7 @@ async function loadQueue() {
|
|||
el(
|
||||
'p',
|
||||
canSkipPeers
|
||||
? 'Three agents still have to confirm these. You can open one to judge it yourself, or approve it now and skip the peer review.'
|
||||
? 'Each needs three agreeing verdicts: three "same" send it to you, three "different" send it back. You can open one to judge it yourself, or approve it now and skip the peer review.'
|
||||
: 'Three agents still have to confirm these before you can approve them. Maintainers can decide them right away.',
|
||||
'muted',
|
||||
),
|
||||
|
|
@ -359,7 +399,8 @@ function queueBatches(body, items, skipsPeers) {
|
|||
);
|
||||
}
|
||||
body.append(head);
|
||||
const peerCol = (a) => (a.state === 'peer_review' ? `${a.peer_same} of 3 confirmed${a.peer_working ? `, ${a.peer_working} reviewing` : ''}` : 'confirmed');
|
||||
const peerCol = (a) =>
|
||||
a.state === 'peer_review' ? `${a.peer_same} same · ${a.peer_different} different${a.peer_working ? ` · ${a.peer_working} reviewing` : ''}` : 'confirmed';
|
||||
body.append(
|
||||
table(
|
||||
['Attempt', 'Symbol', 'Name', 'Score', 'Peers', 'Approvals', ''],
|
||||
|
|
@ -384,7 +425,8 @@ async function openAttempt(id) {
|
|||
const r = $('attempt-result');
|
||||
r.replaceChildren(el('p', f.summary || ''));
|
||||
if (a.peerReviews?.length) {
|
||||
r.append(el('h3', `Peer review: ${a.peerReviews.filter((p) => p.verdict === 'same').length} of ${a.peerRequired} confirmed the logic`));
|
||||
const n = (v) => a.peerReviews.filter((p) => p.verdict === v).length;
|
||||
r.append(el('h3', `Peer review: ${n('same')} same, ${n('different')} different (${a.peerRequired} agreeing verdicts decide)`));
|
||||
r.append(table(['Reviewer', 'Verdict', 'Reason (untrusted)'], a.peerReviews.map((p) => [p.reviewer, status(p.verdict === 'same' ? 'exact' : 'revoked'), p.reason?.text ?? ''])));
|
||||
} else if (a.state === 'in_review' && t && t.status !== 'exact' && t.status !== 'match_unresolved') {
|
||||
r.append(el('p', 'Not byte-identical and no peer verdicts recorded.', 'warn'));
|
||||
|
|
@ -604,4 +646,6 @@ api('/api/info')
|
|||
})
|
||||
.catch(() => {});
|
||||
loadProgress().catch((e) => ($('progress-body').textContent = e.message));
|
||||
loadCrew().catch((e) => ($('active-body').textContent = e.message));
|
||||
setInterval(() => loadCrew().catch(() => {}), 60_000);
|
||||
refresh();
|
||||
|
|
|
|||
|
|
@ -53,6 +53,16 @@
|
|||
<div id="progress-body" class="muted">Loading…</div>
|
||||
</section>
|
||||
|
||||
<section id="active-agents" class="plate">
|
||||
<h2>Active agents <span class="live-dot" aria-hidden="true"></span></h2>
|
||||
<div id="active-body" class="muted">Loading…</div>
|
||||
</section>
|
||||
|
||||
<details id="users-plate" class="plate steps-plate">
|
||||
<summary><h2>Users</h2><span class="hint" id="users-count"></span></summary>
|
||||
<div id="users-body"></div>
|
||||
</details>
|
||||
|
||||
<details class="plate steps-plate" id="guide">
|
||||
<summary><h2>Start here: put your AI to work</h2><span class="hint">6 steps, about 10 minutes</span></summary>
|
||||
<div class="explainer">
|
||||
|
|
|
|||
|
|
@ -485,3 +485,9 @@ details.fold[open] > summary { margin-bottom: 6px; }
|
|||
.panes, .asm-row { grid-template-columns: 1fr; }
|
||||
.asm-row > div + div { border-left: none; }
|
||||
}
|
||||
|
||||
/* --- public crew views */
|
||||
.live-dot { display: inline-block; width: 9px; height: 9px; margin-left: 8px; border-radius: 50%; background: var(--ok); vertical-align: middle; animation: pulse 2s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { opacity: 0.35; } }
|
||||
@media (prefers-reduced-motion: reduce) { .live-dot { animation: none; } }
|
||||
#active-body td div { font-family: var(--font-mono); font-size: 13px; }
|
||||
|
|
|
|||
|
|
@ -333,17 +333,35 @@ test('peer review: three other agents must agree, by reading only', async () =>
|
|||
assert.equal(ctx.tasks.get(task.id).state, 'in_review');
|
||||
assert.ok(ctx.peers.confirmed(attempt));
|
||||
|
||||
// A single "different" sends the next attempt back with the reason.
|
||||
// One "different" is not enough: it stays in review until three agree, here after a mixed round.
|
||||
const second = Number(
|
||||
ctx.store.run(`INSERT INTO attempts (task_id, user_id, base_commit, files_json, state, created_at) VALUES (?, ?, ?, '{}', 'peer_review', ?)`, task.id, authorId, ctx.repo.head(), now()).lastInsertRowid,
|
||||
);
|
||||
await call('POST', '/api/peer-reviews/claim', { attemptId: second }, r4);
|
||||
const no = await call('POST', `/api/peer-reviews/${second}/verdict`, { verdict: 'different', reason: 'the original checks the flag before the loop, this does not' }, r4);
|
||||
assert.equal(no.body.state, 'needs_cleanup');
|
||||
const verdict = async (reviewer, v) => {
|
||||
await call('POST', '/api/peer-reviews/claim', { attemptId: second }, reviewer);
|
||||
return (await call('POST', `/api/peer-reviews/${second}/verdict`, { verdict: v, reason: v === 'same' ? 'the same branches, calls and returns' : 'the original checks the flag before the loop, this does not' }, reviewer)).body;
|
||||
};
|
||||
assert.equal((await verdict(r4, 'different')).state, 'peer_review', 'one objection does not send it back');
|
||||
assert.equal((await verdict(r1, 'different')).state, 'peer_review');
|
||||
const mixed = await verdict(r2, 'same');
|
||||
assert.deepEqual([mixed.state, mixed.same, mixed.different], ['peer_review', 1, 2], 'a split vote lets another reviewer in');
|
||||
const no = await verdict(r3, 'different');
|
||||
assert.equal(no.state, 'needs_cleanup', 'three objections send it back');
|
||||
assert.equal(ctx.peers.verdicts(second)[0].reason.untrusted, true);
|
||||
ctx.store.run(`UPDATE tasks SET state = 'done' WHERE id = ?`, task.id);
|
||||
});
|
||||
|
||||
test('the crew view is public and shows no account details', async () => {
|
||||
const token = await activeUser('crewview');
|
||||
await call('GET', '/api/tasks', null, token); // the agent checks in
|
||||
const r = await request('GET', '/api/crew');
|
||||
assert.equal(r.status, 200);
|
||||
assert.ok(r.body.users.some((u) => u.name === 'crewview'));
|
||||
assert.ok(r.body.active.some((a) => a.name === 'crewview'), 'an agent that just called in is active');
|
||||
const text = JSON.stringify(r.body);
|
||||
for (const secret of ['label', 'token', 'password', 'status']) assert.equal(text.includes(`"${secret}"`), false, `no ${secret} field`);
|
||||
});
|
||||
|
||||
test('remote checks are queued in the database and run by the worker', async () => {
|
||||
const user = await activeUser('rcheck');
|
||||
const realCheck = ctx.verifier.check;
|
||||
|
|
@ -443,7 +461,7 @@ test('split: the approved part of a batch merges while the rest waits for review
|
|||
ctx.verifier.check = realCheck;
|
||||
});
|
||||
|
||||
test('batches: one compile, auto-approval, peer review, return as a whole, carry-over, one merge', async () => {
|
||||
test('batches: one compile, auto-approval, peer review, one function sent back, carry-over', async () => {
|
||||
const author = await activeUser('bauthor');
|
||||
const peers = [await activeUser('bpeer1'), await activeUser('bpeer2'), await activeUser('bpeer3')];
|
||||
await ctx.accounts.createUser('breviewer', 'reviewer', PASSWORD);
|
||||
|
|
@ -459,7 +477,8 @@ test('batches: one compile, auto-approval, peer review, return as a whole, carry
|
|||
// The compiler is stubbed: A byte-identical, B not; B's code fingerprint decides carry-over.
|
||||
let bHash = 'hash-b-1';
|
||||
const realCheck = ctx.verifier.check;
|
||||
ctx.verifier.check = async () => ({
|
||||
// A function is only compiled when its code is in the file.
|
||||
ctx.verifier.check = async (files) => ({
|
||||
check: {
|
||||
annotationErrors: [],
|
||||
units: [{
|
||||
|
|
@ -467,7 +486,7 @@ test('batches: one compile, auto-approval, peer review, return as a whole, carry
|
|||
functions: [
|
||||
{ id: a, status: STATUS.EXACT, score: 1, file: 'src/batch.cpp', codeHash: 'hash-a', relocations: [], size: { ours: 8, original: 8 } },
|
||||
{ id: b, status: STATUS.MISMATCH, score: 0.9, file: 'src/batch.cpp', codeHash: bHash, relocations: [], size: { ours: 8, original: 8 } },
|
||||
],
|
||||
].filter((f) => (files['src/batch.cpp'] ?? '').includes(f.id === a ? 'int fa()' : 'int fb()')),
|
||||
}],
|
||||
},
|
||||
rec: { conflicts: [], unknown: [], learned: [], learnedAddresses: [] },
|
||||
|
|
@ -490,22 +509,27 @@ test('batches: one compile, auto-approval, peer review, return as a whole, carry
|
|||
assert.equal(r1[a].state, 'approved', 'byte-identical needs no human');
|
||||
assert.equal(r1[b].state, 'peer_review');
|
||||
|
||||
// Round 1: a peer claim covers the batch's waiting functions; two confirm, one finds a difference.
|
||||
// Round 1: a peer claim covers the batch's waiting functions; all three find a difference.
|
||||
const pc = await call('POST', '/api/peer-reviews/claim', { attemptId: r1[b].attemptId }, peers[0]);
|
||||
assert.deepEqual(pc.body.functions.map((f) => f.symbol), [b]);
|
||||
await call('POST', `/api/peer-reviews/${r1[b].attemptId}/verdict`, { verdict: 'same', reason: 'same logic, checked branches and calls' }, peers[0]);
|
||||
await call('POST', '/api/peer-reviews/claim', { attemptId: r1[b].attemptId }, peers[1]);
|
||||
await call('POST', `/api/peer-reviews/${r1[b].attemptId}/verdict`, { verdict: 'same', reason: 'same logic, checked branches and calls' }, peers[1]);
|
||||
await call('POST', '/api/peer-reviews/claim', { attemptId: r1[b].attemptId }, peers[2]);
|
||||
await call('POST', `/api/peer-reviews/${r1[b].attemptId}/verdict`, { verdict: 'different', reason: 'the original checks the flag first, this does not' }, peers[2]);
|
||||
const back = await batchState(s1.body.batchId);
|
||||
assert.equal(back.state, 'returned', 'the batch goes back as a whole');
|
||||
assert.ok(back.functions.every((f) => ['returned', 'needs_cleanup'].includes(f.state)), JSON.stringify(back.functions));
|
||||
for (const t of peers) {
|
||||
await call('POST', '/api/peer-reviews/claim', { attemptId: r1[b].attemptId }, t);
|
||||
await call('POST', `/api/peer-reviews/${r1[b].attemptId}/verdict`, { verdict: 'different', reason: 'the original checks the flag first, this does not' }, t);
|
||||
}
|
||||
// Only B goes back; A merges on its own and the batch is over.
|
||||
const back = Object.fromEntries((await batchState(s1.body.batchId)).functions.map((f) => [f.symbol, f]));
|
||||
assert.equal(back[b].state, 'needs_cleanup');
|
||||
assert.equal(back[a].state, 'approved', 'the rest of the batch is not sent back');
|
||||
await ctx.merger.pump();
|
||||
assert.doesNotMatch(ctx.repo.show(ctx.repo.head(), 'src/batch.cpp'), /int fb\(\)/, 'the function sent back is not merged');
|
||||
assert.equal(ctx.store.get('SELECT state FROM tasks WHERE symbol_id = ?', a).state, 'done');
|
||||
assert.equal((await batchState(s1.body.batchId)).state, 'returned');
|
||||
|
||||
// Round 2: the author fixes B (new fingerprint) and resubmits; three peers confirm.
|
||||
// Round 2: the author fixes B (new fingerprint) and resubmits it; three peers confirm.
|
||||
bHash = 'hash-b-2';
|
||||
for (const id of taskIds) assert.equal((await call('POST', '/api/tasks/claim', { taskId: id }, author)).status, 200);
|
||||
const s2 = await submit();
|
||||
const taskOfB = claim.body.tasks.find((t) => t.symbol === b).task;
|
||||
assert.equal((await call('POST', '/api/tasks/claim', { taskId: taskOfB }, author)).status, 200);
|
||||
const s2 = await call('POST', '/api/batches', { taskIds: [taskOfB], baseCommit: ctx.repo.head(), files: { 'src/batch.cpp': source } }, author);
|
||||
const r2 = Object.fromEntries((await settle(s2.body.batchId)).functions.map((f) => [f.symbol, f]));
|
||||
assert.equal(r2[b].state, 'peer_review', 'changed code gets reviewed again');
|
||||
for (const t of peers) {
|
||||
|
|
@ -520,7 +544,7 @@ test('batches: one compile, auto-approval, peer review, return as a whole, carry
|
|||
await ctx.merger.pump();
|
||||
const merged = await batchState(s2.body.batchId);
|
||||
assert.equal(merged.state, 'merged', JSON.stringify(merged));
|
||||
assert.match(ctx.repo.git(['log', '-1', '--format=%B']), /Decompile 2 functions in src\/batch\.cpp[\s\S]*Peer-reviewed-by/);
|
||||
assert.match(ctx.repo.git(['log', '-1', '--format=%B']), new RegExp(`Decompile ${b}[\\s\\S]*Peer-reviewed-by`));
|
||||
assert.equal(ctx.store.get('SELECT confirmed FROM func_status WHERE symbol_id = ?', b).confirmed, 1);
|
||||
assert.equal(ctx.store.get('SELECT code_hash FROM func_status WHERE symbol_id = ?', a).code_hash, 'hash-a');
|
||||
|
||||
|
|
|
|||
34
tools/deploy.sh
Normal file
34
tools/deploy.sh
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#!/bin/sh
|
||||
# Deploys the committed HEAD to the coordinator without taking the site down (deploy/update.sh).
|
||||
# tools/deploy.sh [--td]
|
||||
# --td also publishes the td binaries from dist/ (node tools/build-td/build.mjs) for download and
|
||||
# self-update. TD_HOST names the ssh host (default: td-server).
|
||||
set -eu
|
||||
|
||||
HOST="${TD_HOST:-td-server}"
|
||||
cd "$(dirname "$0")/.."
|
||||
[ -z "$(git status --porcelain -- src deploy template ghidra_scripts toolchain)" ] || echo "note: uncommitted changes are not deployed; this deploys HEAD ($(git rev-parse --short HEAD))"
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
git archive --format=tar.gz -o "$tmp/release.tar.gz" HEAD
|
||||
scp -q "$tmp/release.tar.gz" "$HOST:/tmp/clank-release.tar.gz"
|
||||
|
||||
if [ "${1:-}" = "--td" ]; then
|
||||
scp -q dist/td-windows-x64.exe dist/td-linux-x64 dist/SHA256SUMS "$HOST:/tmp/"
|
||||
fi
|
||||
|
||||
ssh "$HOST" "set -e
|
||||
d=\$(mktemp -d)
|
||||
tar -xzf /tmp/clank-release.tar.gz -C \"\$d\"
|
||||
rm -f /tmp/clank-release.tar.gz
|
||||
if [ '${1:-}' = '--td' ]; then
|
||||
D=/var/lib/togetherdecomp/downloads
|
||||
for f in td-windows-x64.exe td-linux-x64 SHA256SUMS; do
|
||||
install -o togetherdecomp -g togetherdecomp -m 0644 /tmp/\$f \$D/\$f.new && mv \$D/\$f.new \$D/\$f && rm -f /tmp/\$f
|
||||
done
|
||||
chmod 0755 \$D/td-linux-x64
|
||||
echo 'td binaries published'
|
||||
fi
|
||||
sh \"\$d/deploy/update.sh\" \"\$d\"
|
||||
rm -rf \"\$d\""
|
||||
Loading…
Add table
Add a link
Reference in a new issue