openvibers/openvibe.codes Commits

Codes race mode: two harnesses, tests judge, the merge wins (#444)

OP OpenVibers committed 5 h ago · b2e092fafd · 19 files, +1232 −38

* Codes race mode: two harnesses, the tests judge, the merge wins

* Account data covers races and their checks

Changes

ddiff --git a/README.md b/README.md
iindex 1ede6d4..bb4db2d 100644
---- a/README.md
++++ b/README.md
@@@ -27,6 +27,7 @@ One place to send a coding task to the agent that fits it — by what the task n
| Knowledge | `/knowledge`, `GET/POST /api/v1/knowledge` ([server/http/knowledge.js](server/http/knowledge.js), [server/domain/knowledge.js](server/domain/knowledge.js)) | Workspace knowledge (Know K5): when a run ends, Codes captures what it taught — the agent's `Learned:` lines, a failure with its reason, the command that failed and the commands the repository uses — redacts secrets and stores it as a private row with its source run (`codes_knowledge`, migration 0025). The next run's job carries the relevant rows in its prompt before the brief, for every harness ([harness/job.js](harness/job.js) `agentPrompt`), so a pin bump, a test command or a trap is recalled without re-reading files. The page shows every row with its run, and edits, pins, shares (with a Network project's team) and deletes it; private by default. The same rows are exported and erased with the account ([server/account-data.js](server/account-data.js)); `ov-know`'s `knowledge_search`/`knowledge_add` ([know/knowledge.js](https://github.com/OpenVibers/openvibe-agents)) answer them for the person's own agents. |
| Skills hub | `/skills`, `/api/v1/skills`, `/api/v1/me/skills` ([server/http/skills-pages.js](server/http/skills-pages.js), [server/domain/skills.js](server/domain/skills.js)) | Skills, rules, MCP servers, commands and hooks kept per person and per project, versioned (`codes_skills`, `codes_skill_versions`, migration 0028): every save is an immutable version, a rollback copies an old version into a new one, and the body is rendered through the markup gate. Sharing is `openvibe-sdk/share`'s four audiences (`public`, `link`, `people`, `private`); only `public` is in the gallery, and the share sheet is the SDK's at `/api/v1/skill/:id/share`. A person adds a record to their sync, and `openvibe-link skills sync` writes it into Claude Code, Codex, OpenCode and Command Code inside one marked block, with a dry run, a diff and an undo. |
| The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |
++| Race mode | `/run/races/:id`, `POST /run/tasks/:id/race`, `POST /api/v1/tasks/:id/race` ([server/domain/races.js](server/domain/races.js)) | One task, two harnesses, one budget (compare-arena-ecosystem.md §5): two ordinary tasks with the same prompt, repository, ref and limits (`actor_ref->'race'` names the race and the slot), each opening a pull request. When both end, the repository's own check runs on each pushed branch through a `kind: 'check'` job (the check discovers the workflow; [harness/check.js](harness/check.js)) and each side's share of passing steps becomes a `test_suite` judgement. The comparison is recorded to OpenVibe.Bench as one `bench.match@1` (source `codes`, agent entrants `agent:<harness>+<model>#<settings hash>`, receipts, shown order, one budget); the pull request a person merges adds an `implicit` judgement to the same match id (from the `github.pr.updated` delivery, or the page's GitHub read). Codes needs `bench.judgement.cast` on audience `openvibe.bench` for that write; without the grant the race still runs and the match is skipped with a log line. Migration 0041. |
| Task API (services and people) | `POST /api/v1/tasks`, `GET /api/v1/tasks`, `GET /api/v1/tasks/:id`, `GET /api/v1/tasks/:id/events`, `POST /api/v1/tasks/:id/continue`, `POST /api/v1/tasks/:id/stop`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`; `permission` is `review` (read-only), `edit` or `full`. A person may call the same routes with their own Network access token (`openvibe-codes task`, [docs/cli.md](docs/cli.md)): their token names them, so they see and act on every task they own, whoever started it. `GET /api/v1/tasks?state=running|needs|done` lists them. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404); the owner needs no token for their own tasks. The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. **A task's attempts** (stage C2, migration 0010 `codes_attempts`): every Run job is one attempt, so a continue, a switch to another agent and a wrap-up are attempts on one timeline, never new tasks. `GET /api/v1/tasks/:id` answers `codes.task@2`: the task's own fields at the top level, exactly as `codes.task@1` had them, and, added to them, `attempts[]` (the timeline) and `outcome` — `done`, `unfinished` (with what is left) or `stopped`. `GET /api/v1/tasks/:id/events` is the task's event stream (server-sent events): the session's lines in order with the attempt each belongs to, the attempts themselves (started, ended with why), a heartbeat every 15 s, and a resume with `Last-Event-ID`. `POST /api/v1/tasks/:id/continue` starts the next attempt now (with `agent` it switches), `POST /api/v1/tasks/:id/stop` stops the running attempt and nothing carries it on. |
| Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |
| Improve OpenVibe | `/improve` | Every OpenVibe repository, from the pinned contracts' service manifests (the services that run, then the shared libraries), and the path from a change to a reviewed pull request. |
@@@ -120,6 +121,7 @@ No path in Codes sends or accepts a shared loopback key (tested by grep and at r
- [test/harnesses.test.js](test/harnesses.test.js) — the catalog validated at boot as `platform.harness-offer@1` / `platform.agent-offer@1`.
- [test/harnesses-api.test.js](test/harnesses-api.test.js) — `GET /api/v1/harnesses` and `POST /api/v1/harnesses/route`, their 422 problems and their per-actor limits.
- [test/tasks-api.test.js](test/tasks-api.test.js) — `codes.task.*`: tokens refused, `codes.no_project`, a start in the person's project, idempotency, reading through Run with the machine's result cut to the contract, another person's or service's task 404, cancel; a person's own token starting a `review` task, listing (running/needs/done), reading, continuing and stopping their own, and a token for the Codes audience accepted as the same person.
++- [test/race.test.js](test/race.test.js) — race mode: two jobs with one budget, the match body as `bench.match@1` (entrants, receipts, test_suite share), the merge as an implicit judgement on the same id, the page's two sides, and no grant → no Bench call, no failure.
- [test/harness-cli.test.js](test/harness-cli.test.js) — the `openvibe-codes` command (`agents`, `route`, `run`, `runs`, `show`, `resume`) and its exit code.
- [test/task-cli.test.js](test/task-cli.test.js) — `openvibe-codes task` against a stand-in Codes: the flags become the API's body and query, `--follow` reads the event stream as lines and ends on the outcome, and the exit code (0 done, 1 refused or needs someone, 2 the command line or the token).
- [test/moved.test.js](test/moved.test.js) — every former console address answers 301 or 308 to the same path on openvibe.services, and what stays here is untouched.
@@@ -149,6 +151,10 @@ Behind `CODES_AI_CATALOG` (off by default), the model pickers read OpenVibe.AI's
`/api/v1` limits who calls it, once the caller is known and before any work: `server/http/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4. A signed-in person counts as `user:usr_…`, anyone else by address. Past a limit: `429` problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and `codes_rate_limited_total{limit,window}`. Reads: `CODES_LIMITS_MINUTE` / `CODES_LIMITS_HOUR` (120 / 3000); the router: 30 a minute, 600 an hour. Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in and the public pages (the per-address limit bounds them).
++### Race matches and OpenVibe.Bench
++
++Race mode records a finished comparison at OpenVibe.Bench's `POST /api/v1/matches` with Codes' Network service token for audience `openvibe.bench`, scope `bench.judgement.cast` (the same capability Bench requires on the route). Network must grant it to the `codes` client. An absent grant (`invalid_scope`, or Bench's 401/403) is not an error: the race runs, the match is skipped once with `[Codes] race … match skipped: …`, and the race page says it was not recorded.
++
### Telemetry
Each HTTP request is aggregated by route template, method and status class, never by raw URL. Once a minute one `http.request` sample per key, plus the active-requests, p95 latency and event-loop-lag gauges, is written to this service's own `telemetry_samples` table (openvibe-sdk 0.46 telemetry, the `platform.telemetry-sample@1` shape, migration 0006); rows older than 14 days are deleted daily. `/shared` files, static assets, health, readiness and `/metrics` are not counted. It is wired in [server/app.js](server/app.js), which mounts openvibe-sdk's `telemetryMiddleware` (a no-op until init), and [server/index.js](server/index.js), which calls `telemetry.init()` at production start and runs the 14-day retention and the flush on stop.
ddiff --git a/harness/check.js b/harness/check.js
iindex 21f52a2..115e2ea 100644
---- a/harness/check.js
++++ b/harness/check.js
@@@ -89,18 +89,23 @@ function parseCheckArgs(raw, env = process.env) {
const repo = parseRepo(a.repo, env);
const sha = String(a.sha == null ? '' : a.sha).trim();
-- if (!SHA.test(sha)) throw new Error('sha must be a 40-character hex commit');
-- const workflow = String(a.workflow == null ? '' : a.workflow).trim();
-- if (!isWorkflowPath(workflow)) throw new Error('workflow must be a path under .github/workflows/ ending in .yml or .yaml');
-- const job = String(a.job == null ? '' : a.job).trim();
-- if (!JOB_ID.test(job)) throw new Error('job must be a workflow job id');
-- const event = a.event;
-- if (event !== 'push' && event !== 'pull_request') throw new Error('event must be push or pull_request');
++ if (sha && !SHA.test(sha)) throw new Error('sha must be a 40-character hex commit');
++ // A race checks the branch a side pushed, before Codes has read its commit: the ref names it and the check
++ // resolves the commit itself. A job that names a sha is still checked against it exactly.
let ref = null;
if (a.ref != null && a.ref !== '') {
ref = String(a.ref).trim();
if (!REF.test(ref) || ref.startsWith('-') || ref.startsWith('/') || ref.includes('..') || ref.includes('//')) throw new Error('ref must be a branch or tag name');
}
++ if (!sha && !ref) throw new Error('sha or ref is required');
++ const workflow = String(a.workflow == null ? '' : a.workflow).trim();
++ if (workflow && !isWorkflowPath(workflow)) throw new Error('workflow must be a path under .github/workflows/ ending in .yml or .yaml');
++ const job = String(a.job == null ? '' : a.job).trim();
++ if (job && !JOB_ID.test(job)) throw new Error('job must be a workflow job id');
++ // Both named, or neither: with neither, the check runs the repository's own first runnable workflow job.
++ if (!!workflow !== !!job) throw new Error('workflow and job go together, or neither for the repository’s own check');
++ const event = a.event;
++ if (event !== 'push' && event !== 'pull_request') throw new Error('event must be push or pull_request');
const matrix = parseMatrix(a.matrix);
let timeout_ms = DEFAULT_TIMEOUT_MS;
if (a.timeout_ms != null) {
@@@ -195,6 +200,53 @@ function jobIn(wf, jobId) {
return job;
}
++/** The events a workflow listens for (`on:` is a string, a list or a map of events). */
++function workflowTriggers(wf) {
++ const on = wf && (wf.on !== undefined ? wf.on : wf.true);
++ if (typeof on === 'string') return [on];
++ if (Array.isArray(on)) return on.filter((x) => typeof x === 'string');
++ if (on && typeof on === 'object') return Object.keys(on);
++ return [];
++}
++
++/**
++ * The repository's own check, when the job names no workflow: the first workflow job that listens for pull requests
++ * (or any event) and that act can run in host mode. Sorted by file name, so the same repository always gets the same
++ * check. When nothing is runnable the first job is still named: the check's own skip reason says why it was left out.
++ * → { workflow, job } or null when the repository has no workflow.
++ */
++function discoverWorkflow(work) {
++ const dir = path.join(work, '.github', 'workflows');
++ let names;
++ try { names = fs.readdirSync(dir); } catch { return null; }
++ const parsed = [];
++ for (const name of names.filter((n) => /\.ya?ml$/i.test(n)).sort()) {
++ const file = path.join(dir, name);
++ let st;
++ try { st = fs.statSync(file); } catch { continue; }
++ if (!st.isFile() || st.size > MAX_WORKFLOW_BYTES) continue;
++ try {
++ const source = fs.readFileSync(file, 'utf8');
++ parsed.push({ workflow: `.github/workflows/${name}`, source, wf: parsedWorkflow(source), triggers: [] });
++ parsed[parsed.length - 1].triggers = workflowTriggers(parsed[parsed.length - 1].wf);
++ } catch { /* a workflow act could not read is not the repository's check */ }
++ }
++ const forPullRequests = (p) => p.triggers.includes('pull_request');
++ const ordered = [...parsed.filter(forPullRequests), ...parsed.filter((p) => !forPullRequests(p))];
++ // Within a workflow, a job that sounds like the check (test, lint, build) comes before a deploy or a publish.
++ const rank = (id) => (/test/i.test(id) ? 0 : /check|ci|lint|build/i.test(id) ? 1 : 2);
++ for (const p of ordered) {
++ const jobs = Object.keys(p.wf.jobs || {}).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
++ const runnable = jobs.find((id) => {
++ try { return skipReason(p.source, id) === null; } catch { return false; }
++ });
++ if (runnable) return { workflow: p.workflow, job: runnable };
++ }
++ const first = ordered[0];
++ const firstJob = first && Object.keys(first.wf.jobs || {})[0];
++ return first && firstJob ? { workflow: first.workflow, job: firstJob } : null;
++}
++
/** The workflow's jobs.<id> as a plain object; an unreadable workflow or a missing job is an error, not a skip. */
function workflowJob(source, jobId) {
return jobIn(parsedWorkflow(source), jobId);
@@@ -576,6 +628,7 @@ async function runCheck({ input, emit = () => {}, writeResult = () => {}, env =
kind: 'check',
conclusion,
...(reason ? { reason: clean(reason) } : {}),
++ ...(job && job.workflow ? { workflow: job.workflow, job: job.job, sha: job.sha } : {}),
steps: steps.map((step) => ({
name: step.name,
conclusion: step.conclusion || 'cancelled',
@@@ -592,24 +645,39 @@ async function runCheck({ input, emit = () => {}, writeResult = () => {}, env =
job = parseCheckArgs(input, env);
dir = fs.mkdtempSync(path.join(tmp, 'codes-check-'));
const work = path.join(dir, 'repo');
-- say({ t: 'step', text: `Cloning ${scrub(job.repo)} at ${job.sha.slice(0, 12)}` });
++ say({ t: 'step', text: `Cloning ${scrub(job.repo)} at ${job.sha ? job.sha.slice(0, 12) : job.ref}` });
await git(['init', '-q', work], { timeout: 60_000 });
await git(['remote', 'add', 'origin', job.repo], { cwd: work });
const auth = gitAuthFor(job.repo, env);
-- try {
-- await git(['fetch', '--depth', '1', '--no-tags', 'origin', job.sha], { cwd: work, timeout: 300_000, ...(auth.env ? { env: auth.env } : {}) });
-- } catch (first) {
-- // Some servers do not serve an arbitrary commit by sha; the branch the scheduler names can still hold it.
-- if (!job.ref) throw new Error(cloneProblem(first.message, job.repo));
-- await git(['fetch', '--no-tags', 'origin', job.ref], { cwd: work, timeout: 300_000, ...(auth.env ? { env: auth.env } : {}) }).catch((e) => { throw new Error(cloneProblem(e.message, job.repo)); });
-- try { await git(['cat-file', '-e', `${job.sha}^{commit}`], { cwd: work }); } catch { throw new Error(`commit ${job.sha} is not in ${job.repo}`); }
++ if (job.sha) {
++ try {
++ await git(['fetch', '--depth', '1', '--no-tags', 'origin', job.sha], { cwd: work, timeout: 300_000, ...(auth.env ? { env: auth.env } : {}) });
++ } catch (first) {
++ // Some servers do not serve an arbitrary commit by sha; the branch the scheduler names can still hold it.
++ if (!job.ref) throw new Error(cloneProblem(first.message, job.repo));
++ await git(['fetch', '--no-tags', 'origin', job.ref], { cwd: work, timeout: 300_000, ...(auth.env ? { env: auth.env } : {}) }).catch((e) => { throw new Error(cloneProblem(e.message, job.repo)); });
++ try { await git(['cat-file', '-e', `${job.sha}^{commit}`], { cwd: work }); } catch { throw new Error(`commit ${job.sha} is not in ${job.repo}`); }
++ }
++ await git(['checkout', '-q', '--detach', job.sha], { cwd: work });
++ } else {
++ // A race names the branch its side pushed: fetch it, and let the commit it points at be the one checked.
++ await git(['fetch', '--no-tags', 'origin', job.ref], { cwd: work, timeout: 300_000, ...(auth.env ? { env: auth.env } : {}) })
++ .catch((e) => { throw new Error(cloneProblem(e.message, job.repo)); });
++ await git(['checkout', '-q', '--detach', 'FETCH_HEAD'], { cwd: work });
}
-- await git(['checkout', '-q', '--detach', job.sha], { cwd: work });
const head = (await git(['rev-parse', 'HEAD'], { cwd: work })).trim().toLowerCase();
-- if (head !== job.sha) throw new Error('the cloned commit is not the one asked for');
++ if (job.sha && head !== job.sha) throw new Error('the cloned commit is not the one asked for');
++ job.sha = head;
if (auth.env) say({ t: 'step', text: 'Cloned with this machine\'s token for a private repository' });
++ if (!job.workflow) {
++ const found = discoverWorkflow(work);
++ if (!found) throw new Error('the repository has no workflow under .github/workflows/');
++ job.workflow = found.workflow;
++ job.job = found.job;
++ say({ t: 'step', text: `Using ${found.workflow} job ${found.job}` });
++ }
const workflowFile = path.join(work, job.workflow);
let realWorkflow;
try { realWorkflow = fs.realpathSync(workflowFile); } catch { throw new Error(`the workflow ${job.workflow} is not in the repository at ${job.sha}`); }
@@@ -689,6 +757,6 @@ function tail(raw) {
module.exports = {
ACT_VERSION, ACT_URL, ACT_SHA256, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS,
-- parseCheckArgs, parseArgs: parseCheckArgs, skipReason, workflowJob, eventPayload, parseActLine, outputText, prepareAct,
++ parseCheckArgs, parseArgs: parseCheckArgs, skipReason, workflowJob, discoverWorkflow, eventPayload, parseActLine, outputText, prepareAct,
runCheck, tail,
};
ddiff --git a/harness/job.js b/harness/job.js
iindex 792e118..39897d9 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -260,7 +260,8 @@ function parseArgs(raw, env = process.env) {
const resumeIn = a.resume == null || a.resume === '' ? env.RESUME : a.resume;
const resume = typeof resumeIn === 'string' && /^[A-Za-z0-9._:-]{1,200}$/.test(resumeIn) ? resumeIn : null;
const wanted = jobEnv(a);
-- const limits = kind === 'bench' && a.limits && typeof a.limits === 'object' ? {
++ // A task's own budget (benchmark cells and race sides carry one): the same clamps whatever the kind.
++ const limits = a.limits && typeof a.limits === 'object' ? {
ms: Number.isFinite(Number(a.limits.ms)) && Number(a.limits.ms) > 0 ? Math.min(300000, Number(a.limits.ms)) : null,
tokens: Number.isFinite(Number(a.limits.tokens)) && Number(a.limits.tokens) > 0 ? Math.min(1000000, Number(a.limits.tokens)) : null,
usd: Number.isFinite(Number(a.limits.usd)) && Number(a.limits.usd) > 0 ? Math.min(100, Number(a.limits.usd)) : null,
ddiff --git a/migrations/0041_races.sql b/migrations/0041_races.sql
nnew file mode 100644
iindex 0000000..2c2aa2a
---- /dev/null
++++ b/migrations/0041_races.sql
@@@ -0,0 +1,52 @@
++-- phase: expand
++-- Race mode (compare-arena-ecosystem.md §5, "Votes where people already work"): one task, two harnesses, one budget.
++-- The two sides are ordinary run_tasks rows (actor_ref->'race' names the race and the slot); this table keeps the
++-- race itself, the Bench match it records, and the two check jobs that run the repository's own workflow on the
++-- branches the sides pushed. SIDES is a JSON array (slot a/b): the harness + model, the entrant id and its settings
++-- hash, the pushed branch and pull request, the receipts and the check. A merge arrives later and adds a judgement
++-- to the same match id, so MATCH_BODY and MATCH_RECORDED stay here.
++CREATE TABLE IF NOT EXISTS codes_races (
++ id text COLLATE "C" PRIMARY KEY, -- rce_<ULID>
++ subject text COLLATE "C" NOT NULL, -- usr_… who races
++ project_id text COLLATE "C" NOT NULL, -- prj_… where both jobs run
++ source_task_id text COLLATE "C", -- tsk_… the "Race this task" was pressed on
++ repo text NOT NULL,
++ ref text,
++ prompt text NOT NULL,
++ permission text COLLATE "C" NOT NULL DEFAULT 'edit',
++ budget jsonb, -- { usd, tokens, ms }: the same for both sides
++ sides jsonb NOT NULL, -- [{ slot, task_id, agent, model, entrant, ... }]
++ shown jsonb, -- the order the sides are shown in (position bias)
++ state text COLLATE "C" NOT NULL, -- running, checking, matched, skipped, failed
++ match_id text COLLATE "C", -- mat_… the bench.match@1 id; one id, two judgements
++ match_body jsonb, -- the body last recorded (the merge adds to it)
++ match_recorded boolean NOT NULL DEFAULT false,
++ merged_slot text COLLATE "C", -- a/b: the side whose pull request was merged
++ merged_judged boolean NOT NULL DEFAULT false,
++ error text,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL
++);
++CREATE INDEX IF NOT EXISTS codes_races_subject ON codes_races (subject, created_at DESC);
++CREATE INDEX IF NOT EXISTS codes_races_state ON codes_races (state) WHERE state IN ('running', 'checking');
++
++-- One check job per side: the repository's own workflow, at the branch the side pushed. Its run.job.* events are
++-- claimed here (not on run_tasks): a check never appears in a person's task list and never starts a follow-up.
++CREATE TABLE IF NOT EXISTS codes_race_checks (
++ id text COLLATE "C" PRIMARY KEY, -- rch_<ULID>
++ race_id text COLLATE "C" NOT NULL REFERENCES codes_races (id) ON DELETE CASCADE,
++ slot text COLLATE "C" NOT NULL, -- a, b
++ subject text COLLATE "C" NOT NULL,
++ project_id text COLLATE "C" NOT NULL,
++ repo text NOT NULL,
++ ref text NOT NULL, -- the branch the side pushed
++ job_id text COLLATE "C", -- Run's job_<ULID>
++ state text COLLATE "C" NOT NULL, -- queued, running, succeeded, failed, cancelled, expired, skipped
++ result jsonb,
++ error text,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ UNIQUE (race_id, slot)
++);
++CREATE INDEX IF NOT EXISTS codes_race_checks_job ON codes_race_checks (job_id) WHERE job_id IS NOT NULL;
++CREATE INDEX IF NOT EXISTS codes_race_checks_race ON codes_race_checks (race_id);
ddiff --git a/package-lock.json b/package-lock.json
iindex 4b9b863..3a36c74 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.422.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.233.0",
"pg": "^8.23.0",
@@@ -1231,9 +1231,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.419.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
-- "integrity": "sha512-VxrTdeHchT8gvl2wWC9ASaId92en7ZKfJ8F/0hKzCAD0f2Y00rPrqgBWF9BNPoLGARoyQvsMbhBEhSkhxGNnjA==",
++ "version": "0.422.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.422.0",
++ "integrity": "sha512-xQQq7NQLM9iQve8dR4Hc2EUp75x/FZYfAsHumsmf6dGHjIy05PMX0PlYmxw5L52VXKOj+r7mRwlcehRqgMoLSw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex dd2c529..61eb720 100644
---- a/package.json
++++ b/package.json
@@@ -30,7 +30,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.422.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.233.0",
"pg": "^8.23.0",
ddiff --git a/public/css/run.css b/public/css/run.css
iindex 48ece93..a9092b2 100644
---- a/public/css/run.css
++++ b/public/css/run.css
@@@ -44,3 +44,17 @@
/* Placement A1: the one line saying why this task went where it did, first on the timeline. */
.run-placed { margin: 8px 0 12px; padding: 8px 12px; border-left: 3px solid var(--accent, #3b82f6); border-radius: 6px; background: var(--bg-card, #131c2e); overflow-wrap: anywhere; }
++
++/* Race mode (/run/races/:id): the same brief on two harnesses, side by side; one column on a phone. */
++.run-race-start { margin: 24px 0; padding: 16px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); max-width: 820px; display: grid; gap: 12px; }
++.run-race-link { padding: 10px 14px; border-left: 3px solid var(--accent, #3b82f6); border-radius: 6px; background: var(--bg-card, #131c2e); }
++.run-race-match { padding: 8px 12px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); overflow-wrap: anywhere; }
++.run-race-sides { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; margin: 16px 0; }
++.run-race-side { min-width: 0; border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 14px; background: var(--bg-card, #131c2e); overflow-wrap: anywhere; }
++.run-race-side h2 { margin-top: 0; overflow-wrap: anywhere; }
++.run-race-side .run-result, .run-race-side .run-timeline { max-width: none; }
++.run-race-receipts { font-weight: 600; }
++@media (max-width: 760px) {
++ .run-race-sides { grid-template-columns: minmax(0, 1fr); }
++ .run-race-side { padding: 12px; }
++}
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 1bbf318..843d461 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -35,6 +35,12 @@ const TABLES = [
// before calling it, so a default delete here would lose the ids first).
erase: { keep: 'erased with its task-keyed rows by the service extraErase' },
},
++ // Race mode (compare-arena-ecosystem.md §5): the person's races, their two sides and the match body they
++ // recorded. The check rows hang under a race (codes_race_checks.race_id, ON DELETE CASCADE) and leave with it.
++ {
++ table: 'codes_races', subject: 'subject', file: 'races.json', order: 'created_at',
++ columns: ['id', 'project_id', 'source_task_id', 'repo', 'ref', 'prompt', 'permission', 'budget', 'sides', 'shown', 'state', 'match_id', 'match_body', 'match_recorded', 'merged_slot', 'merged_judged', 'error', 'created_at', 'updated_at'],
++ },
{ table: 'codes_user_settings', subject: 'subject', file: 'settings.json', order: 'updated_at' },
{ table: 'codes_model_plans', subject: 'subject', file: 'model-plans.json', order: 'updated_at' },
// The files a person attached to a message (owner 2026-11-07): what they are and which turn they belong to. The
@@@ -152,6 +158,9 @@ const EXEMPT = {
// created_by is the author of a version, not the person whose account is being erased: a record's versions leave
// with the record (codes_skills), and a version never outlives it.
codes_skill_versions: 'created_by is the author of a version, not a subject column; versions leave with their skill',
++ // A check row names the person (subject) but hangs under its race (race_id, ON DELETE CASCADE): it is exported
++ // and erased with codes_races, never on its own.
++ codes_race_checks: 'the check jobs of a race: they hang under codes_races and leave with it (ON DELETE CASCADE)',
};
module.exports = { create, TABLES, TASK_KEYED, TOPICS, EXEMPT };
ddiff --git a/server/app.js b/server/app.js
iindex 9069841..eaac272 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -39,6 +39,7 @@ const { createConductorApi } = require('./http/conductor-api');
const { createAttachmentsApi } = require('./http/attachments-api');
const { createRunClient } = require('./domain/run-client');
const { createRunTasks } = require('./domain/run-tasks');
++const { createRaces } = require('./domain/races');
const { createConnections } = require('./domain/connections');
const { createAiCatalog } = require('./domain/ai-catalog');
const { createRunTokens } = require('./domain/run-tokens');
@@@ -169,6 +170,14 @@ async function createApp(opts = {}) {
// bindings (a terminal result with a PR binds it) — wired to each other here, not in a constructor.
ctx.prBindings = require('./domain/pr-bindings').createPrBindings({ store, tasks, outbox, config, log });
tasks.attachBindings(ctx.prBindings);
++ // Race mode (compare-arena-ecosystem.md §5): one task, two harnesses, one budget, the tests first and the merge
++ // later. Its sides are ordinary tasks, so tasks tells it when a side ended; its check jobs are its own rows.
++ ctx.races = opts.races || createRaces({
++ store, config, tasks, run, github: ctx.github, forge: ctx.forge, fleet, harnesses,
++ fetchImpl: fetchImpl || globalThis.fetch, log, now: store.now,
++ recorder: opts.matchRecorder || null, random: opts.random || Math.random,
++ });
++ tasks.attachRaces(ctx.races);
const app = express();
ctx.indexnow = require('openvibe-shared/indexnow').createIndexNow({ host: config.baseUrl, key: (opts.env || process.env).INDEXNOW_KEY || '', log: (message) => log.warn(message) });
@@@ -250,7 +259,7 @@ async function createApp(opts = {}) {
const accountSend = opts.accountSend || (config.oauth.clientSecret
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl || globalThis.fetch })
: null);
-- const consumer = createEventsConsumer({ config, store, tasks, run, accountData, accountSend, bindings: ctx.prBindings, log });
++ const consumer = createEventsConsumer({ config, store, tasks, run, accountData, accountSend, bindings: ctx.prBindings, races: ctx.races, log });
ctx.accountData = accountData;
ctx.eventsConsumer = consumer;
ctx.publisher = createPublisher({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
@@@ -320,7 +329,7 @@ async function createApp(opts = {}) {
}
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createSkillsApi({ config, skills: ctx.skills, actorLimits: ctx.actorLimits, log }));
-- app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, modelPlans, fleet: ctx.fleet, conductor: ctx.conductor, attachments: ctx.attachments, actorLimits: ctx.actorLimits, sharing, log }));
++ app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, modelPlans, fleet: ctx.fleet, conductor: ctx.conductor, attachments: ctx.attachments, actorLimits: ctx.actorLimits, sharing, races: ctx.races, log }));
// The composer's files: upload before send (with progress), or as the multipart form without JavaScript.
app.use('/api/v1', createAttachmentsApi({ attachments: ctx.attachments, actorLimits: ctx.actorLimits, config, keys, log }));
app.use('/api/v1', createMeApi({ config, store, sso, keys, run, harnesses, tasks, userSettings: ctx.userSettings, github: ctx.github, approvals: ctx.approvals, repoPicker: ctx.repoPicker, forge: ctx.forge, actorLimits: ctx.actorLimits, log }));
@@@ -377,7 +386,7 @@ async function createApp(opts = {}) {
c4Routes = require('./http/c4').createC4Routes(ctx);
app.use(c4Routes);
app.use(pageRoutes);
-- app.use(createRunRoutes({ config, store, sso, harnesses, run, tasks, sessions, keys, sharing, fetchImpl: fetchImpl || globalThis.fetch, log }));
++ app.use(createRunRoutes({ config, store, sso, harnesses, run, tasks, sessions, keys, sharing, races: ctx.races, fetchImpl: fetchImpl || globalThis.fetch, log }));
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/harnesses">the agents</a>, <a href="/improve">Improve OpenVibe</a>, or the developer platform at <a href="https://openvibe.services">openvibe.services</a>.</p>` }));
// eslint-disable-next-line no-unused-vars
ddiff --git a/server/domain/github.js b/server/domain/github.js
iindex 5d2cc55..2805fd7 100644
---- a/server/domain/github.js
++++ b/server/domain/github.js
@@@ -216,6 +216,7 @@ function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch,
merge: mergeWord(doc),
merged: !!doc.merged,
merge_commit_sha: doc.merge_commit_sha ? String(doc.merge_commit_sha).slice(0, 40) : null,
++ head_sha: sha && /^[0-9a-f]{40}$/i.test(sha) ? sha.toLowerCase() : null,
ci,
title: doc.title ? String(doc.title).slice(0, 200) : null,
branch: doc.head && doc.head.ref ? String(doc.head.ref).slice(0, 120) : null,
ddiff --git a/server/domain/races.js b/server/domain/races.js
nnew file mode 100644
iindex 0000000..b372eb3
---- /dev/null
++++ b/server/domain/races.js
@@@ -0,0 +1,651 @@
++'use strict';
++
++/**
++ * Race mode (compare-arena-ecosystem.md §5, "Votes where people already work"; plan §7 piece 5).
++ *
++ * One task, two harnesses, one budget:
++ *
++ * const races = createRaces({ store, config, tasks, run, github, forge, fleet, harnesses, log });
++ * const race = await races.start({ subject, project, task, agents: ['codex', 'opencode'] });
++ * races.onTaskEnded(row) // a side's run_tasks row reached a terminal state
++ * races.applyCheck(check, event) // one of the two check jobs ended
++ * races.handleGithubEvent(event) // a pull request was merged (the implicit judge)
++ * races.checkMerges(race) // the page's fallback read of GitHub
++ * races.present(race) // the run page's two sides
++ *
++ * The two sides are ordinary run_tasks rows (actor_ref->'race' names the race and the slot) started through
++ * tasks.start with the same prompt, repository, ref and limits: two Run jobs on the person's machines, one budget
++ * each. When both end, Codes runs the repository's own check workflow on each pushed branch through the check job
++ * (harness/check.js, kind 'check' with a ref instead of a sha; the check discovers the workflow). The checks' results
++ * become a test_suite judgement, and the whole comparison is recorded to OpenVibe.Bench as one bench.match@1.
++ * The pull request the person merges later adds an implicit judgement to the SAME match id (Bench merges judgements
++ * by id). Nothing here holds a Bench token: the service token asks Network for bench.judgement.cast on
++ * audience openvibe.bench, and when that grant is absent the race still runs and only the match is skipped, with one
++ * log line (the grant is added on Network separately).
++ *
++ * Entrants are agents: agent:<harness>+<model>#<16 hex of the canonical settings' sha256>, so a change of model,
++ * permission or effort is a new entrant (bench.entrant@1).
++ */
++const crypto = require('crypto');
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++
++const RACE_STATE = new Set(['running', 'checking', 'finalizing', 'matched', 'skipped', 'failed']);
++const CHECK_TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired', 'skipped']);
++const TASK_TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
++const CHECK_STATE = { queued: 'queued', placed: 'queued', running: 'running', succeeded: 'succeeded', failed: 'failed', cancelled: 'cancelled', expired: 'expired' };
++const OUTCOME = { succeeded: 'succeeded', failed: 'failed', cancelled: 'cancelled', expired: 'timed_out' };
++const BENCH_SCOPE = 'bench.judgement.cast';
++const BENCH_AUDIENCE = 'openvibe.bench';
++
++const asObject = (v) => {
++ if (v && typeof v === 'object' && !Array.isArray(v)) return v;
++ if (typeof v === 'string') { try { const p = JSON.parse(v); return p && typeof p === 'object' && !Array.isArray(p) ? p : null; } catch { return null; } }
++ return null;
++};
++const asArray = (v) => (Array.isArray(v) ? v : []);
++const num = (v) => (Number.isFinite(Number(v)) && Number(v) >= 0 ? Number(v) : null);
++const int = (v) => (Number.isInteger(Number(v)) && Number(v) >= 0 ? Number(v) : null);
++const clampText = (v, max) => String(v == null ? '' : v).slice(0, max);
++
++/** The canonical settings an agent entrant pins: sorted keys, null for what the side did not name. */
++function settingsOf(side) {
++ return {
++ effort: side.effort || null,
++ harness: side.agent || null,
++ model: side.model || 'auto',
++ permission: side.permission || 'edit',
++ };
++}
++
++/** 16 hex of the sha256 of the settings' canonical JSON: the same settings always name the same entrant. */
++function hashSettings(settings) {
++ const canonical = {};
++ for (const key of Object.keys(settings || {}).sort()) canonical[key] = settings[key] == null ? null : settings[key];
++ return crypto.createHash('sha256').update(JSON.stringify(canonical)).digest('hex').slice(0, 16);
++}
++
++/** The entrant id (bench.entrant@1): agent:<harness>+<model>#<settings hash>. */
++function entrantOf(side) {
++ const model = String(side.model || 'auto').replace(/[^A-Za-z0-9._/:-]/g, '-').slice(0, 200) || 'auto';
++ const harness = String(side.agent || 'openvibe').replace(/[^a-z0-9._-]/g, '-').slice(0, 60) || 'openvibe';
++ return `agent:${harness}+${model}#${hashSettings(settingsOf(side))}`;
++}
++
++/** A repository URL as owner/name, for matching a pull-request event to a race. */
++function repoSlug(url) {
++ try {
++ const u = new URL(String(url || ''));
++ const parts = u.pathname.replace(/\.git$/i, '').split('/').filter(Boolean);
++ return parts.length === 2 ? `${parts[0]}/${parts[1]}`.toLowerCase() : null;
++ } catch { return null; }
++}
++
++/** The tokens in a run's log, split in and out (when the log has none, receiptsOf keeps the total). */
++function splitTokens(result) {
++ const log = asArray(result && result.log);
++ let tokensIn = 0;
++ let tokensOut = 0;
++ for (const line of log) {
++ if (!line || typeof line !== 'object' || line.t !== 'usage') continue;
++ tokensIn += Number(line.input_tokens) || 0;
++ tokensOut += Number(line.output_tokens) || 0;
++ }
++ return { tokensIn: tokensIn || null, tokensOut: tokensOut || null };
++}
++
++/** The answer's style covariates (bench.match@1 receipts): its length and how much markdown it carries. */
++function styleOf(text) {
++ const s = String(text || '');
++ const length = s.length || null;
++ const items = (s.match(/^#{1,6}\s|^\s*[-*+]\s|^\s*\d+[.)]\s|\*\*[^*]+\*\*/gm) || []).length;
++ return { length_chars: length, markdown_items: items || (length ? 0 : null) };
++}
++
++/** A side's receipts from its task and its last attempt: cost, tokens, wall time and tool calls. */
++function receiptsOf(task, attempt, result) {
++ const split = splitTokens(result);
++ // A harness that reports one total (no usage lines) still contributes its tokens: the contract splits receipts
++ // in and out, so the total goes in as the input side and the output side is zero, never a made-up split.
++ const total = num(result && result.tokens) != null ? num(result.tokens) : (attempt && num(attempt.tokens));
++ const tokensIn = split.tokensIn != null ? split.tokensIn : (total != null ? total : null);
++ const tokensOut = split.tokensOut != null ? split.tokensOut : (total != null ? 0 : null);
++ const started = attempt && attempt.started_at ? Date.parse(String(attempt.started_at)) : 0;
++ const finished = attempt && attempt.finished_at ? Date.parse(String(attempt.finished_at)) : 0;
++ const wall = started && finished > started ? finished - started : num(result && result.duration_ms);
++ const toolCalls = attempt && Number.isInteger(Number(attempt.steps)) ? Number(attempt.steps) : null;
++ const style = styleOf((asObject(result) || {}).summary);
++ return {
++ outcome: OUTCOME[task.state] || 'failed',
++ cost_usd: attempt && num(attempt.cost_usd) != null ? num(attempt.cost_usd) : num(result && result.cost_usd),
++ tokens_in: tokensIn,
++ tokens_out: tokensOut,
++ wall_ms: wall == null ? null : Math.round(wall),
++ tool_calls: toolCalls,
++ length_chars: style.length_chars,
++ markdown_items: style.markdown_items,
++ };
++}
++
++/** A test-suite score from a check result: the share of the steps that passed, 0..1; null when it never ran. */
++function scoreOf(result) {
++ const doc = asObject(result);
++ if (!doc || doc.kind !== 'check' || doc.conclusion === 'skipped') return null;
++ const counted = asArray(doc.steps).filter((s) => s && ['success', 'failure', 'cancelled'].includes(s.conclusion));
++ if (counted.length) return counted.filter((s) => s.conclusion === 'success').length / counted.length;
++ return doc.conclusion === 'success' ? 1 : null;
++}
++
++/**
++ * The OpenVibe.Bench recorder. It asks Network for bench.judgement.cast on audience openvibe.bench. A missing client,
++ * a missing grant (Network's invalid_scope, or Bench's 401/403) is an `absent` answer, never a throw: the race ran,
++ * and only the match is skipped. The caller logs one line.
++ */
++function createMatchRecorder({ config = {}, fetchImpl = globalThis.fetch, tokens = undefined, log = console } = {}) {
++ const client = tokens !== undefined ? tokens : (config.oauth && config.oauth.clientSecret ? createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret, audience: BENCH_AUDIENCE, scope: BENCH_SCOPE, fetch: fetchImpl,
++ }) : null);
++ const url = `${String((config.bench && config.bench.url) || 'https://bench.openvibe.services').replace(/\/+$/, '')}/api/v1/matches`;
++ async function record(match) {
++ if (!client) return { ok: false, absent: true, reason: `Codes has no Network client credentials for ${BENCH_AUDIENCE}` };
++ let headers;
++ try {
++ headers = await client.authHeaders();
++ } catch (e) {
++ return { ok: false, absent: true, reason: `Codes has no ${BENCH_SCOPE} on ${BENCH_AUDIENCE} (${clampText(e && e.message, 200)})` };
++ }
++ let res;
++ try {
++ res = await fetchImpl(url, {
++ method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' },
++ body: JSON.stringify(match), signal: AbortSignal.timeout(20000),
++ });
++ } catch (e) {
++ return { ok: false, error: `Bench did not answer: ${clampText(e && e.message, 200)}` };
++ }
++ const doc = await res.json().catch(() => null);
++ if (res.status === 401 || res.status === 403) {
++ return { ok: false, absent: true, reason: `Bench refused ${BENCH_SCOPE} (${res.status})` };
++ }
++ if (!res.ok) return { ok: false, error: `Bench answered ${res.status}${doc && doc.detail ? `: ${clampText(doc.detail, 200)}` : ''}` };
++ return { ok: true, created: doc ? doc.created !== false : undefined, match: doc && doc.match ? doc.match : match };
++ }
++ return { record, enabled: !!client, url };
++}
++
++/**
++ * The bench.match@1 a race records: source codes, task kind code, the two sides as agent entrants with their receipts,
++ * the shown order, the same budget for both, and the test_suite judgement when the checks answered. A merged side is
++ * added as an implicit judgement before the first write when it is already known.
++ */
++function buildMatch(race, sides, { at = null, mergedSlot = null } = {}) {
++ const slots = sides.map((side) => ({
++ slot: side.slot,
++ entrant: side.entrant || entrantOf(side),
++ run_id: side.task_id || null,
++ receipts: side.receipts || undefined,
++ }));
++ const judgements = [];
++ const scores = {};
++ for (const side of sides) {
++ if (side.check && Number.isFinite(Number(side.check.score))) scores[side.slot] = Math.min(1, Math.max(0, Number(side.check.score)));
++ }
++ if (Object.keys(scores).length) {
++ const best = Math.max(...Object.values(scores));
++ const winners = Object.keys(scores).filter((slot) => scores[slot] === best);
++ judgements.push({
++ judge: 'test_suite', winner: winners.length === 1 ? winners[0] : 'tie', scores,
++ by: { type: 'suite' }, at: at || new Date(race.created_at).toISOString(),
++ });
++ }
++ if (mergedSlot && slots.some((x) => x.slot === mergedSlot)) {
++ judgements.push({ judge: 'implicit', winner: mergedSlot, signal: 'merged', by: { type: 'person' }, at: at || new Date().toISOString() });
++ }
++ const promptHash = crypto.createHash('sha256').update(String(race.prompt || '')).digest('hex');
++ const budget = asObject(race.budget) || {};
++ const when = at || new Date(race.created_at).toISOString();
++ return {
++ id: race.match_id,
++ source: 'codes',
++ task: {
++ kind: 'code', category: 'code.change', prompt_hash: promptHash, public: false,
++ repo: clampText(race.repo, 300),
++ },
++ slots,
++ shown: asArray(race.shown).length ? asArray(race.shown) : slots.map((x) => x.slot),
++ blind: false,
++ budget: { usd: num(budget.usd), ms: int(budget.ms), machine_class: null },
++ judgements,
++ at: when,
++ };
++}
++
++function createRaces({ store, config = {}, run = null, tasks = null, github = null, forge = null, fleet = null, harnesses = null, fetchImpl = globalThis.fetch, log = console, now = null, random = Math.random, recorder = null } = {}) {
++ if (!store) throw new TypeError('races need a store');
++ const db = store.db;
++ const clock = now || (() => store.now());
++ const bench = recorder || createMatchRecorder({ config, fetchImpl, log });
++ const finalizing = new Set();
++
++ const byId = (id) => db.maybe('SELECT * FROM codes_races WHERE id = $1', [id]);
++ const iso = (ms) => new Date(Number(ms)).toISOString();
++
++ async function updateSides(race, sides) {
++ return await db.one('UPDATE codes_races SET sides = $2::jsonb, updated_at = $3 WHERE id = $1 RETURNING *', [race.id, JSON.stringify(sides), clock()]);
++ }
++
++ async function updateSide(race, slot, patch) {
++ const sides = asArray(race.sides).map((side) => (side.slot === slot ? { ...side, ...patch } : side));
++ return await updateSides(race, sides);
++ }
++
++ /** Two harnesses the person can run: from their fleet when it answers, else the catalog's first two capable ones. */
++ async function suggest(subject) {
++ const catalog = harnesses ? harnesses.list().map((h) => h.id) : [];
++ const usable = (id) => !catalog.length || catalog.includes(id);
++ let ids = [];
++ if (fleet && subject) {
++ try {
++ const rows = await fleet.agents(subject, harnesses);
++ ids = rows.filter((row) => row && row.installed !== false && !['full', 'empty', 'offline'].includes(row.state))
++ .map((row) => row.agent).filter(usable);
++ } catch { /* no fleet: the catalog answers */ }
++ }
++ const unique = [...new Set(ids)];
++ if (unique.length >= 2) return unique.slice(0, 2);
++ if (!unique.length) {
++ const capable = harnesses ? harnesses.list().filter((h) => h.capabilities && h.capabilities.edit).map((h) => h.id) : [];
++ unique.push(...(capable.length >= 2 ? capable.slice(0, 2) : ['codex', 'opencode'].filter(usable)));
++ }
++ for (const id of catalog) {
++ if (unique.length >= 2) break;
++ if (!unique.includes(id)) unique.push(id);
++ }
++ return unique.slice(0, 2);
++ }
++
++ function checkAgent(agent) {
++ if (!harnesses) return;
++ if (!harnesses.list().some((h) => h.id === agent)) {
++ const error = new Error(`${agent} is not a harness in GET /api/v1/harnesses`);
++ error.code = 'codes.agent_unknown';
++ throw error;
++ }
++ }
++
++ /** Start a race: two tasks, the same brief and repository and limits, one budget each, on the person's machines. */
++ async function start({ subject, project, task = null, agents = null, prompt = null, repo = null, ref = null, permission = 'edit', effort = null, model = null, limits = null, mode = null, via = null } = {}) {
++ if (!subject || !project) throw Object.assign(new Error('a race needs the person and their project'), { code: 'codes.race_invalid' });
++ const chosen = asArray(agents).map(String).filter(Boolean).slice(0, 2);
++ let pair = chosen.length === 2 ? chosen : await suggest(subject);
++ if (chosen.length === 1) pair = [chosen[0], ...pair.filter((id) => id !== chosen[0])].slice(0, 2);
++ if (pair.length < 2 || pair[0] === pair[1]) throw Object.assign(new Error('a race needs two different harnesses'), { code: 'codes.race_agents' });
++ for (const agent of pair) checkAgent(agent);
++ const raceId = store.newId('rce');
++ const matchId = store.newId('mat');
++ const at = clock();
++ // One budget for both: the source task's first attempt when racing an existing task, else the limits named.
++ let same = limits && typeof limits === 'object' ? { ...limits } : null;
++ if (!same && task && tasks && tasks.attempts) {
++ const first = (await tasks.attempts.list(task.id))[0] || null;
++ if (first && (num(first.budget_usd) != null || num(first.budget_tokens) != null)) {
++ same = { usd: num(first.budget_usd), tokens: num(first.budget_tokens) };
++ }
++ }
++ const baseArgs = {
++ repo: repo || (task && task.repo), ref: ref != null ? ref : (task && task.ref), prompt: prompt || (task && task.prompt),
++ permission: permission || (task && task.permission) || 'edit',
++ ...(same ? { limits: same } : {}),
++ ...(model || (task && task.model) ? { model: model || task.model } : {}),
++ ...(effort ? { effort } : {}),
++ push: { branch: null, pull_request: true },
++ };
++ if (!baseArgs.repo || !baseArgs.prompt) throw Object.assign(new Error('a race needs a repository and a prompt'), { code: 'codes.race_invalid' });
++ const shown = pair.map((_, i) => String.fromCharCode(97 + i));
++ for (let i = shown.length - 1; i > 0; i--) {
++ const j = Math.floor(random() * (i + 1));
++ [shown[i], shown[j]] = [shown[j], shown[i]];
++ }
++ const sides = pair.map((agent, index) => {
++ const slot = String.fromCharCode(97 + index);
++ return {
++ slot, agent, model: baseArgs.model || null, effort: baseArgs.effort || null,
++ permission: baseArgs.permission, task_id: null, state: 'queued',
++ branch: null, pr_url: null, pr_number: null, receipts: null,
++ check: { state: 'pending', score: null, conclusion: null },
++ entrant: entrantOf({ agent, model: baseArgs.model || null, effort: baseArgs.effort || null, permission: baseArgs.permission }),
++ };
++ });
++ const budget = { usd: num(same && same.usd), tokens: num(same && same.tokens), ms: int(same && same.ms) };
++ await db.exec(
++ `INSERT INTO codes_races (id, subject, project_id, source_task_id, repo, ref, prompt, permission, budget, sides, shown, state, match_id, created_at, updated_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11::jsonb, 'running', $12, $13, $13)`,
++ [raceId, subject, project, task ? task.id : null, baseArgs.repo, baseArgs.ref || null, baseArgs.prompt, baseArgs.permission,
++ JSON.stringify(budget), JSON.stringify(sides), JSON.stringify(shown), matchId, at],
++ );
++ let race = await byId(raceId);
++ for (const side of sides) {
++ try {
++ const row = await tasks.start({
++ subject, project,
++ args: {
++ repo: baseArgs.repo, ref: baseArgs.ref || null, prompt: baseArgs.prompt, agent: side.agent,
++ model: baseArgs.model || null, permission: baseArgs.permission, effort: baseArgs.effort || null,
++ // A race side is never re-planned: the person chose the two harnesses.
++ race: true,
++ ...(same ? { limits: same } : {}),
++ // The two jobs must not choose the same branch name, and each race side has its own.
++ push: { branch: `openvibe-codes/${String(raceId).slice(-10)}-${side.slot}`, pull_request: true },
++ },
++ via, key: `${raceId}-${side.slot}`, actorRef: { race: { id: raceId, slot: side.slot, match: matchId } }, mode,
++ });
++ const next = asArray(race.sides).map((x) => (x.slot === side.slot ? { ...x, task_id: row.id, state: row.state } : x));
++ race = await updateSides(race, next);
++ } catch (e) {
++ log.warn(`[Codes] race ${raceId}: side ${side.slot} (${side.agent}) did not start: ${clampText(e && e.message, 300)}`);
++ race = await updateSide(race, side.slot, { state: 'failed', error: clampText(e && e.message, 500) });
++ }
++ }
++ const started = asArray(race.sides).filter((s) => s.task_id).length;
++ if (started < 2) {
++ race = await db.one(`UPDATE codes_races SET state = 'failed', error = $2, updated_at = $3 WHERE id = $1 RETURNING *`,
++ [raceId, `only ${started} of 2 race jobs started`, clock()]);
++ }
++ return race;
++ }
++
++ /** A race started from a finished (or running) task: "Race this task". */
++ async function startFromTask(task, { agents = null, effort = null, model = null, limits = null, mode = null, via = null } = {}) {
++ if (!task) throw Object.assign(new Error('no such task'), { code: 'codes.task_not_found' });
++ return await start({
++ subject: task.subject, project: task.project_id, task, agents, effort, model, limits,
++ permission: 'edit', mode, via,
++ });
++ }
++
++ /** The race a task is a side of, or null. */
++ async function ofTask(task) {
++ const ref = asObject(task && task.actor_ref);
++ const raceRef = ref && asObject(ref.race);
++ return raceRef && raceRef.id ? await byId(raceRef.id) : null;
++ }
++
++ /** One side's run_tasks row and its last attempt, read fresh. */
++ async function sideTask(side) {
++ if (!side || !side.task_id) return { task: null, attempt: null };
++ const task = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [side.task_id]);
++ const attempt = task ? (await tasks.attempts.list(task.id)).slice(-1)[0] || null : null;
++ return { task, attempt };
++ }
++
++ /** The side after its task ended: state, the pushed branch and pull request, the resolved model, the receipts. */
++ async function sideAfterEnd(race, side) {
++ const { task, attempt } = await sideTask(side);
++ if (!task) return side;
++ const result = asObject(task.result) || {};
++ const push = asObject(result.push) || {};
++ const model = clampText(result.resolved_model || (attempt && attempt.model) || side.model || '', 200) || null;
++ const next = {
++ ...side, state: task.state, model,
++ branch: push.branch || side.branch || null,
++ pr_url: push.pull_request || side.pr_url || null,
++ pr_number: push.pull_request ? Number((/\/pull\/(\d+)/.exec(push.pull_request) || [])[1]) || null : side.pr_number,
++ receipts: receiptsOf(task, attempt, result),
++ };
++ next.entrant = entrantOf(next);
++ return next;
++ }
++
++ async function bothEnded(race) {
++ const sides = asArray(race.sides);
++ if (sides.length !== 2) return false;
++ for (const side of sides) {
++ const { task } = await sideTask(side);
++ if (!task || !TASK_TERMINAL.has(task.state)) return false;
++ }
++ return true;
++ }
++
++ /** A side's task reached a terminal state: keep its side, and when both have, run the checks. */
++ async function onTaskEnded(row) {
++ const ref = asObject(row && row.actor_ref);
++ const raceRef = ref && asObject(ref.race);
++ if (!raceRef || !raceRef.id) return row;
++ let race = await byId(raceRef.id);
++ if (!race) return row;
++ const sides = [];
++ for (const side of asArray(race.sides)) sides.push(side.slot === raceRef.slot ? await sideAfterEnd(race, side) : side);
++ race = await updateSides(race, sides);
++ if (race.state !== 'running') return row;
++ if (!(await bothEnded(race))) return row;
++ const claimed = await db.maybe("UPDATE codes_races SET state = 'checking', updated_at = $2 WHERE id = $1 AND state = 'running' RETURNING *", [race.id, clock()]);
++ if (!claimed) return row;
++ await runChecks(claimed);
++ return row;
++ }
++
++ /** Start the repository's check on each side's pushed branch (the check job discovers the workflow). */
++ async function runChecks(race) {
++ if (!run || !run.submit) {
++ await finishRace(race.id, { reason: 'Codes has no Run client to start the check jobs' });
++ return;
++ }
++ for (const side of asArray(race.sides)) {
++ if (side.check && CHECK_TERMINAL.has(side.check.state)) continue;
++ if (!side.branch) {
++ await skipCheck(race.id, side.slot, 'the side did not push a branch to check');
++ continue;
++ }
++ const existing = await db.maybe('SELECT * FROM codes_race_checks WHERE race_id = $1 AND slot = $2', [race.id, side.slot]);
++ if (existing && existing.job_id) continue;
++ const checkId = existing ? existing.id : store.newId('rch');
++ if (!existing) {
++ await db.exec(
++ `INSERT INTO codes_race_checks (id, race_id, slot, subject, project_id, repo, ref, state, created_at, updated_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, 'queued', $8, $8) ON CONFLICT (race_id, slot) DO NOTHING`,
++ [checkId, race.id, side.slot, race.subject, race.project_id, race.repo, side.branch, clock()],
++ );
++ }
++ try {
++ // A repository on OpenVibe Git is checked with the person's credential line, as a code job is; a
++ // GitHub repository uses the machine's own GITHUB_TOKEN.
++ const git = forge && forge.jobEnv ? await forge.jobEnv(race.subject, race.repo).catch(() => null) : null;
++ const job = await run.submit({
++ subject: race.subject, project: race.project_id,
++ args: { kind: 'check', repo: race.repo, ref: side.branch, event: 'pull_request', ...(git ? { env: git } : {}) },
++ key: checkId,
++ });
++ await db.exec('UPDATE codes_race_checks SET job_id = $2, state = $3, updated_at = $4 WHERE id = $1',
++ [checkId, job.id, CHECK_STATE[job.state] || 'queued', clock()]);
++ race = await updateSide(race, side.slot, { check: { id: checkId, state: CHECK_STATE[job.state] || 'queued', job_id: job.id, score: null } });
++ } catch (e) {
++ log.warn(`[Codes] race ${race.id}: the check for side ${side.slot} did not start: ${clampText(e && e.message, 300)}`);
++ await skipCheck(race.id, side.slot, `the check job did not start: ${clampText(e && e.message, 300)}`);
++ }
++ }
++ await maybeFinish(race.id);
++ }
++
++ async function skipCheck(raceId, slot, reason) {
++ const check = await db.maybe('SELECT * FROM codes_race_checks WHERE race_id = $1 AND slot = $2', [raceId, slot]);
++ if (check && CHECK_TERMINAL.has(check.state)) return;
++ if (check) {
++ await db.exec("UPDATE codes_race_checks SET state = 'skipped', error = $2, updated_at = $3 WHERE id = $1", [check.id, clampText(reason, 500), clock()]);
++ }
++ const race = await byId(raceId);
++ if (!race) return;
++ await updateSide(race, slot, { check: { ...(check ? { id: check.id } : {}), state: 'skipped', reason: clampText(reason, 500), score: null } });
++ }
++
++ /** A run.job.* event of a check job: its result, then the race's test judgement when both sides answered. */
++ async function applyCheck(check, { state, job = null, error = null } = {}) {
++ const next = CHECK_STATE[state] || 'failed';
++ if (CHECK_TERMINAL.has(check.state)) return check;
++ const result = asObject(job && job.result) || null;
++ const updated = await db.one(
++ `UPDATE codes_race_checks SET state = $2, result = $3::jsonb, error = $4, updated_at = $5 WHERE id = $1 RETURNING *`,
++ [check.id, next, result ? JSON.stringify(result) : null, error ? clampText(error, 1000) : null, clock()],
++ );
++ const race = await byId(updated.race_id);
++ if (!race) return updated;
++ const score = scoreOf(updated.result);
++ await updateSide(race, updated.slot, {
++ check: {
++ id: updated.id, state: updated.state, job_id: updated.job_id, conclusion: (updated.result && updated.result.conclusion) || null,
++ score, reason: updated.error || (updated.result && updated.result.reason) || null,
++ steps: asArray(updated.result && updated.result.steps).length || null,
++ },
++ });
++ await maybeFinish(race.id);
++ return updated;
++ }
++
++ /** When every check answered, build and record the match (once). */
++ async function maybeFinish(raceId) {
++ if (finalizing.has(raceId)) return;
++ let race = await byId(raceId);
++ if (!race || race.state !== 'checking') return;
++ const checks = await db.many('SELECT * FROM codes_race_checks WHERE race_id = $1', [raceId]);
++ if (checks.some((c) => !CHECK_TERMINAL.has(c.state))) return;
++ // A side with no branch has no check row: its side says skipped, and that is an answer too.
++ if (asArray(race.sides).some((side) => side.check && !CHECK_TERMINAL.has(side.check.state) && !checks.some((c) => c.slot === side.slot))) return;
++ finalizing.add(raceId);
++ try {
++ const claimed = await db.maybe("UPDATE codes_races SET state = 'finalizing', updated_at = $2 WHERE id = $1 AND state = 'checking' RETURNING *", [raceId, clock()]);
++ if (!claimed) return;
++ await finishRace(claimed.id);
++ } finally {
++ finalizing.delete(raceId);
++ }
++ }
++
++ /** The match body from the two finished sides, recorded to Bench; no grant is a skip with one log line. */
++ async function finishRace(raceId, { reason = null } = {}) {
++ let race = await byId(raceId);
++ if (!race) return null;
++ const sides = [];
++ for (const side of asArray(race.sides)) {
++ const finished = await sideAfterEnd(race, side);
++ const check = await db.maybe('SELECT * FROM codes_race_checks WHERE race_id = $1 AND slot = $2', [race.id, side.slot]);
++ const score = check ? scoreOf(check.result) : null;
++ sides.push({ ...finished, check: { ...(finished.check || {}), state: check ? check.state : 'skipped', score, conclusion: check && check.result ? check.result.conclusion : null } });
++ }
++ race = await updateSides(race, sides);
++ const body = buildMatch(race, sides, { mergedSlot: race.merged_slot || null, at: iso(clock()) });
++ await db.exec('UPDATE codes_races SET match_body = $2::jsonb, updated_at = $3 WHERE id = $1', [race.id, JSON.stringify(body), clock()]);
++ const recorded = await bench.record(body);
++ if (!recorded.ok) {
++ const skipped = !!recorded.absent;
++ const why = recorded.reason || recorded.error || reason || 'the match was not recorded';
++ log.warn(`[Codes] race ${race.id}: ${skipped ? 'match skipped' : 'match not recorded'}: ${clampText(why, 300)}`);
++ await db.exec(`UPDATE codes_races SET state = $2, error = $3, updated_at = $4 WHERE id = $1`,
++ [race.id, skipped ? 'skipped' : 'failed', clampText(why, 1000), clock()]);
++ return null;
++ }
++ await db.exec(`UPDATE codes_races SET state = 'matched', match_recorded = true, merged_judged = $2, error = NULL, updated_at = $3 WHERE id = $1`,
++ [race.id, !!(race.merged_slot), clock()]);
++ return recorded.match || body;
++ }
++
++ /** The merge: one implicit judgement, added to the same match id. Before the match exists it waits in the body. */
++ async function judgeMerged(race, slot) {
++ if (!race || !slot) return null;
++ if (race.merged_judged) return null;
++ if (!race.match_recorded) {
++ await db.exec('UPDATE codes_races SET merged_slot = $2, updated_at = $3 WHERE id = $1 AND merged_slot IS NULL', [race.id, slot, clock()]);
++ return null;
++ }
++ const body = asObject(race.match_body);
++ if (!body) return null;
++ const already = asArray(body.judgements).some((j) => j && j.judge === 'implicit' && j.signal === 'merged');
++ if (!already) {
++ body.judgements = [...asArray(body.judgements), { judge: 'implicit', winner: slot, signal: 'merged', by: { type: 'person' }, at: iso(clock()) }];
++ }
++ await db.exec('UPDATE codes_races SET match_body = $2::jsonb, merged_slot = $3, updated_at = $4 WHERE id = $1', [race.id, JSON.stringify(body), slot, clock()]);
++ if (already) {
++ await db.exec('UPDATE codes_races SET merged_judged = true, updated_at = $2 WHERE id = $1', [race.id, clock()]);
++ return null;
++ }
++ const recorded = await bench.record(body);
++ if (!recorded.ok) {
++ log.warn(`[Codes] race ${race.id}: merged judgement ${recorded.absent ? 'skipped' : 'not recorded'}: ${clampText(recorded.reason || recorded.error, 300)}`);
++ await db.exec('UPDATE codes_races SET merged_judged = true, updated_at = $2 WHERE id = $1', [race.id, clock()]);
++ return null;
++ }
++ await db.exec('UPDATE codes_races SET merged_judged = true, updated_at = $2 WHERE id = $1', [race.id, clock()]);
++ return body;
++ }
++
++ /**
++ * A GitHub delivery through OpenVibe.Events: github.pr.updated with action closed and merged true. The pull
++ * request's repository and number find the race side; the judgement goes to the same match id.
++ */
++ async function handleGithubEvent(event) {
++ if (!event || event.event_type !== 'github.pr.updated') return { outcome: 'ignored:not-a-merge', race_ids: [] };
++ const payload = asObject(event.payload) || {};
++ if (payload.action !== 'closed' || payload.merged !== true) return { outcome: 'ignored:not-a-merge', race_ids: [] };
++ const subject = asObject(event.subject);
++ const m = subject && typeof subject.id === 'string' ? /^([a-z0-9-]+\/[a-z0-9._-]+)#(\d+)$/i.exec(subject.id) : null;
++ if (!m) return { outcome: 'ignored:payload', race_ids: [] };
++ const slug = m[1].toLowerCase();
++ const number = Number(m[2]);
++ const rows = await db.many("SELECT * FROM codes_races WHERE merged_slot IS NULL AND state IN ('running', 'checking', 'finalizing', 'matched') ORDER BY created_at DESC LIMIT 100");
++ for (const race of rows) {
++ if (repoSlug(race.repo) !== slug) continue;
++ const side = asArray(race.sides).find((x) => Number(x.pr_number) === number);
++ if (!side) continue;
++ await db.exec('UPDATE codes_races SET merged_slot = $2, updated_at = $3 WHERE id = $1', [race.id, side.slot, clock()]);
++ await judgeMerged({ ...race, merged_slot: side.slot }, side.slot);
++ return { outcome: 'race:merged', race_ids: [race.id], slot: side.slot };
++ }
++ return { outcome: 'ignored:no_race', race_ids: [] };
++ }
++
++ /** The page's fallback: read the two pull requests from GitHub; a merged one is the implicit judgement. */
++ async function checkMerges(race) {
++ if (!github || !github.pullRequest || !race) return race;
++ for (const side of asArray(race.sides)) {
++ if (!side.pr_url || (race.merged_slot === side.slot)) continue;
++ try {
++ const pr = await github.pullRequest(side.pr_url);
++ if (pr && pr.merged === true) {
++ await db.exec('UPDATE codes_races SET merged_slot = $2, updated_at = $3 WHERE id = $1 AND merged_slot IS NULL', [race.id, side.slot, clock()]);
++ await judgeMerged((await byId(race.id)) || race, side.slot);
++ return await byId(race.id);
++ }
++ } catch (e) {
++ log.warn(`[Codes] race ${race.id}: the pull request ${side.pr_url} could not be read: ${clampText(e && e.message, 200)}`);
++ }
++ }
++ return race;
++ }
++
++ /** The race as the run page and the API present it. */
++ function present(race) {
++ if (!race) return null;
++ return {
++ id: race.id, state: race.state, repo: race.repo, ref: race.ref || null, prompt: race.prompt,
++ source_task_id: race.source_task_id || null, budget: asObject(race.budget) || {},
++ shown: asArray(race.shown), merged_slot: race.merged_slot || null,
++ match: { id: race.match_id, recorded: !!race.match_recorded, state: race.match_recorded ? 'recorded' : (race.state === 'skipped' ? 'skipped' : race.state), error: race.error || null },
++ sides: asArray(race.sides).map((side) => ({
++ slot: side.slot, task_id: side.task_id || null, agent: side.agent, model: side.model || null,
++ entrant: side.entrant || entrantOf(side), state: side.state, branch: side.branch || null,
++ pull_request: side.pr_url || null, receipts: side.receipts || null,
++ check: side.check || null, error: side.error || null,
++ })),
++ created_at: iso(race.created_at), updated_at: iso(race.updated_at),
++ };
++ }
++
++ return {
++ start, startFromTask, ofTask, onTaskEnded, applyCheck, runChecks, maybeFinish, finishRace,
++ judgeMerged, handleGithubEvent, checkMerges, present, byId, suggest, sideTask,
++ bench, enabled: !!run,
++ };
++}
++
++module.exports = {
++ createRaces, createMatchRecorder, buildMatch, entrantOf, settingsOf, hashSettings, receiptsOf, scoreOf, repoSlug,
++ BENCH_SCOPE, BENCH_AUDIENCE, RACE_STATE, CHECK_TERMINAL, TASK_TERMINAL,
++};
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex a5d6653..cf079ad 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -166,7 +166,7 @@ function usageOf(result) {
};
}
--function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null, report = null, sharing = null, attachments: filesStore = null, modelPlans = null, goalVerifier = null }) {
++function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null, report = null, sharing = null, attachments: filesStore = null, modelPlans = null, goalVerifier = null, raceSideEnded = null }) {
const db = store.db;
const who = (row) => ({ subject: row.subject, project: row.project_id });
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
@@@ -550,6 +550,11 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const meta = asObject(saved.actor_ref);
if (meta && meta.bench) await benchAttemptEnded(saved).catch((e) => log.warn(`[Codes] benchmark publish failed for task ${saved.id}: ${String(e && e.message).slice(0, 200)}`));
}
++ // A race side is a finished comparison half: the race keeps it and, when both are in, runs the checks.
++ if (TERMINAL.has(saved.state) && raceSideEnded && saved.actor_ref) {
++ const meta = asObject(saved.actor_ref);
++ if (meta && meta.race) await raceSideEnded(saved).catch((e) => log.warn(`[Codes] race side ${saved.id} was not recorded: ${String(e && e.message).slice(0, 200)}`));
++ }
return saved;
}
@@@ -602,7 +607,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const turnId = claims.length ? (messageId || store.newId('msg')) : null;
// A task with no agent named is `auto` (Codes C9c): the router reads the fleet — who is signed in, whose window
// still has room — and the task is started on the agent it picks. Never a fixed agent, and never an Anthropic one.
-- if (modelPlans?.enabled() && !args.plan && args.kind !== 'bench') {
++ if (modelPlans?.enabled() && !args.plan && !args.race && args.kind !== 'bench') {
const selected = await modelPlans.select(subject, project);
args = { ...args, plan: selected.plan, plan_source: selected.source, preset: selected.preset, model_fallbacks: selected.fallbacks };
}
@@@ -890,6 +895,8 @@ ${String(verdict.check_output || '').slice(0, 50000)}`;
// The conductor's turns are its own: a turn that ended lands its message and the next one starts.
if (row.kind === 'conductor') return conductorTurns ? conductorTurns.onTurnEnd(row.id, row.job_id) : row;
const actorRef = asObject(row.actor_ref);
++ // A race side is exactly one run: its ending is the comparison's receipt, never a follow-up attempt.
++ if (actorRef && actorRef.race) return row;
if (actorRef && actorRef.bench) {
const done = row.state === 'succeeded';
// A benchmark attempt that ran out of money says so, with what it spent: it is not a crash.
@@@ -1340,6 +1347,7 @@ ${String(verdict.check_output || '').slice(0, 50000)}`;
return {
start, refresh, applyJob, applyEvent, recordState, afterEnd, continueTask, stopTask, proof: taskProof,
latestProject, present, who, attempts: attemptStore, setConductor, attachBindings, fundTurn,
++ attachRaces(r) { raceSideEnded = r ? (row) => r.onTaskEnded(row) : null; },
messageTask, pendingMessages,
setForge(f) { forge = f || null; },
};
ddiff --git a/server/events-consumer.js b/server/events-consumer.js
iindex f429d52..452aabb 100644
---- a/server/events-consumer.js
++++ b/server/events-consumer.js
@@@ -48,7 +48,7 @@ function errorText(error) {
return null;
}
--function createEventsConsumer({ config, store, tasks, run, accountData, accountSend = null, bindings = null, log = console, now = store.now }) {
++function createEventsConsumer({ config, store, tasks, run, accountData, accountSend = null, bindings = null, races = null, log = console, now = store.now }) {
const db = store.db;
const secrets = (config.events && config.events.secrets ? config.events.secrets : []).filter((x) => typeof x === 'string' && x.length >= 32);
const stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, refused: 0, failed: 0, last_at: null };
@@@ -73,7 +73,30 @@ function createEventsConsumer({ config, store, tasks, run, accountData, accountS
const seen = await db.maybe('SELECT task_id FROM codes_events_inbox WHERE event_id = $1', [event.event_id]);
if (seen) return { duplicate: true, outcome: null, task_id: seen.task_id || undefined };
const row = await db.maybe('SELECT * FROM run_tasks WHERE job_id = $1', [jobId]);
-- if (!row) return await ignore(event, 'ignored:not_a_codes_task');
++ if (!row) {
++ // A race's check job is its own row (codes_race_checks), never a person's task: its run.job.* events
++ // land on the race's test_suite judgement instead.
++ if (!races) return await ignore(event, 'ignored:not_a_codes_task');
++ const check = await db.maybe('SELECT * FROM codes_race_checks WHERE job_id = $1', [jobId]);
++ if (!check) return await ignore(event, 'ignored:not_a_codes_task');
++ const requester = payload.requester && typeof payload.requester === 'object' ? payload.requester.id : null;
++ if (requester && requester !== check.subject) return await ignore(event, 'ignored:another_subject', check.id);
++ if (payload.project_id && payload.project_id !== check.project_id) return await ignore(event, 'ignored:another_project', check.id);
++ let job = null;
++ if (TERMINAL.has(state)) job = await run.read(jobId, { subject: check.subject, project: check.project_id });
++ const applied = await store.tx(async () => {
++ const inserted = await db.maybe(
++ `INSERT INTO codes_events_inbox (event_id, event_type, task_id, outcome, processed_at)
++ VALUES ($1, $2, $3, $4, $5::timestamptz) ON CONFLICT (event_id) DO NOTHING RETURNING event_id`,
++ [event.event_id, event.event_type, check.id, state, store.iso()],
++ );
++ if (!inserted) return { duplicate: true, outcome: null, task_id: check.id };
++ await races.applyCheck(check, { state, job, error: errorText(payload.error) });
++ await db.exec('UPDATE codes_events_inbox SET outcome = $2 WHERE event_id = $1', [event.event_id, state]);
++ return { duplicate: false, outcome: state, task_id: check.id };
++ });
++ return applied;
++ }
const requester = payload.requester && typeof payload.requester === 'object' ? payload.requester.id : null;
if (requester && requester !== row.subject) return await ignore(event, 'ignored:another_subject', row.id);
if (payload.project_id && payload.project_id !== row.project_id) return await ignore(event, 'ignored:another_project', row.id);
@@@ -139,10 +162,19 @@ function createEventsConsumer({ config, store, tasks, run, accountData, accountS
// duplicate, and the wake below runs once.
const seen = await db.maybe('SELECT task_id, outcome FROM codes_events_inbox WHERE event_id = $1', [event.event_id]);
if (seen) { stats.duplicates++; return res.json({ event_id: event.event_id, duplicate: true, outcome: null }); }
++ // A merge is also a race's implicit judgement (a Codes race that opened both pull requests): it goes to
++ // the same bench.match@1 id, whether or not the task has an AL5 binding.
++ let raceResult = null;
++ if (races) {
++ try { raceResult = await races.handleGithubEvent(event); }
++ catch (err) { log.warn(`[Codes] race judgement for ${event.event_id} failed: ${String(err && err.message).slice(0, 200)}`); }
++ }
if (!bindings) {
-- const result = await ignore(event, 'ignored:no_bindings');
++ const outcome = raceResult && String(raceResult.outcome).startsWith('race:') ? raceResult.outcome : 'ignored:no_bindings';
++ const result = await ignore(event, outcome);
if (result.duplicate) stats.duplicates++; else stats.ignored++;
-- return res.json({ event_id: event.event_id, duplicate: result.duplicate, outcome: result.outcome });
++ return res.json({ event_id: event.event_id, duplicate: result.duplicate, outcome: result.outcome,
++ ...(raceResult && raceResult.race_ids && raceResult.race_ids.length ? { races: raceResult.race_ids } : {}) });
}
let result;
try {
@@@ -164,7 +196,8 @@ function createEventsConsumer({ config, store, tasks, run, accountData, accountS
}
if (result.duplicate) { stats.duplicates++; return res.json({ event_id: event.event_id, duplicate: true, outcome: null }); }
if (String(result.outcome).startsWith('ignored:')) stats.ignored++; else stats.applied++;
-- return res.json({ event_id: event.event_id, duplicate: false, outcome: result.outcome, ...(result.followed_up && result.followed_up.length ? { task_ids: result.followed_up } : {}) });
++ return res.json({ event_id: event.event_id, duplicate: false, outcome: result.outcome, ...(result.followed_up && result.followed_up.length ? { task_ids: result.followed_up } : {}),
++ ...(raceResult && raceResult.race_ids && raceResult.race_ids.length ? { races: raceResult.race_ids } : {}) });
}
if (!RUN_TOPICS.includes(event.event_type)) {
ddiff --git a/server/http/run.js b/server/http/run.js
iindex f8697f5..cd9fc9e 100644
---- a/server/http/run.js
++++ b/server/http/run.js
@@@ -57,7 +57,7 @@ function guestRunPage() {
};
}
--function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globalThis.fetch, log = console, tasks = null, sessions = null, keys = null, sharing = null }) {
++function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globalThis.fetch, log = console, tasks = null, sessions = null, keys = null, sharing = null, races = null }) {
const r = asyncRouter();
const db = store.db;
const form = express.urlencoded({ extended: false, limit: '32kb' });
@@@ -316,6 +316,118 @@ ${i.summary ? html`<p class="run-proof-summary" data-proof-summary>${i.summary}<
</section>`;
}
++ /** "Race this task": the person picks two harnesses (or leaves them to Codes), then sees both sides together. */
++ function raceForm(row) {
++ const opt = (v, label, sel) => html`<option value="${v}"${sel ? raw(' selected') : ''}>${label}</option>`;
++ return html`<section class="run-race-start" data-race-start>
++<h2>Race this task</h2>
++<p class="muted">Run the same brief and repository on two harnesses, with the same budget each. The repository's own check runs on both results, and the pull request you merge wins the match.</p>
++<form method="post" action="/run/tasks/${row.id}/race">
++<div class="run-field-row">
++<label class="run-field"><span>First harness</span><select name="agent_a">${opt('', 'Codes suggests one', true)}${harnesses.list().map((a) => opt(a.id, a.name, false))}</select></label>
++<label class="run-field"><span>Second harness</span><select name="agent_b">${opt('', 'Codes suggests one', true)}${harnesses.list().map((a) => opt(a.id, a.name, false))}</select></label>
++</div>
++<button class="run-btn run-btn-primary" type="submit">Race two harnesses</button>
++</form></section>`;
++ }
++
++ /** A task that is one side of a race says so, and links to both sides. */
++ function raceLink(race, row) {
++ if (!race) return '';
++ const side = (race.sides || []).find((x) => x.task_id === row.id);
++ return html`<p class="run-race-link" data-race="${race.id}"><a href="/run/races/${race.id}">This task is side ${side ? side.slot.toUpperCase() : '?'} of a race</a>: both sides, their checks and the match are on one page.</p>`;
++ }
++
++ /** One side of the race as a card: the harness, its state, the receipts and the check's share. */
++ async function raceSideHtml(race, side, row) {
++ const check = side.check || {};
++ const receipts = side.receipts || {};
++ const bits = [];
++ if (receipts.cost_usd != null) bits.push(`$${Number(receipts.cost_usd).toFixed(4)}`);
++ if (receipts.tokens_in != null || receipts.tokens_out != null) bits.push(`${(receipts.tokens_in || 0) + (receipts.tokens_out || 0)} tokens`);
++ if (receipts.wall_ms != null) bits.push(`${Math.round(receipts.wall_ms / 1000)} s`);
++ if (receipts.tool_calls != null) bits.push(`${receipts.tool_calls} tool calls`);
++ const score = check.score == null ? null : `${Math.round(Number(check.score) * 100)}% of checks passed`;
++ return html`<article class="run-race-side" data-slot="${side.slot}" data-task="${side.task_id || ''}">
++<h2>Side ${side.slot.toUpperCase()} · ${side.agent || 'not started'}${side.model ? html` <small>${side.model}</small>` : ''}</h2>
++<p><span class="run-pill run-pill-${row ? row.state : 'queued'}" data-state>${row ? (STATE_LABEL[row.state] || row.state) : 'Queued'}</span>
++<span class="run-pill" data-check-state="${check.state || 'pending'}">check: ${check.state || 'pending'}</span>
++${score ? html`<span class="run-pill">${score}</span>` : ''}</p>
++<p class="muted">Entrant <code>${side.entrant}</code>${row && row.state !== 'succeeded' && row.error ? html` · ${row.error}` : ''}</p>
++${bits.length ? html`<p class="run-race-receipts" data-receipts>${bits.join(' · ')}</p>` : ''}
++${check.reason ? html`<p class="muted">${check.reason}</p>` : ''}
++${row ? await timelineHtml(row) : html`<p class="muted">This side has not started yet.</p>`}
++${row && TERMINAL.has(row.state) ? resultHtml(row) : ''}
++${row && !TERMINAL.has(row.state) ? html`<p><a href="/run/tasks/${row.id}">Watch this side live</a></p>` : ''}
++</article>`;
++ }
++
++ /** The two sides side by side (one column on a phone, in the order the match showed them). */
++ r.get('/run/races/:id', async (req, res) => {
++ if (!races || !/^rce_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(req.params.id))) {
++ return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such race. <a href="/run">Your tasks</a></p>` }, 404);
++ }
++ let race = await races.byId(req.params.id);
++ if (!race || !signedIn(req) || race.subject !== req.viewer.subject) {
++ return page(req, res, { title: 'Not found', index: false, body: html`<h1>Not found</h1><p>No such race. <a href="/run">Run locally</a></p>` }, 404);
++ }
++ // The two sides are ordinary tasks: the page reads each one (the 10-minute fallback, never a poll), then the
++ // race's own merge fallback. A race whose task events were missed still moves on from here.
++ const rowsBySlot = new Map();
++ for (const side of race.sides || []) {
++ if (!side.task_id) continue;
++ let row = await anyTask(side.task_id);
++ if (row) row = await refresh(row);
++ rowsBySlot.set(side.slot, row);
++ }
++ if (race.state === 'running') {
++ const rows = [...rowsBySlot.values()].filter(Boolean);
++ if (rows.length === 2 && rows.every((row) => TERMINAL.has(row.state))) {
++ try { await races.onTaskEnded(rows[0]); } catch (e) { log.warn(`[Codes] race ${race.id}: ${e.message}`); }
++ race = (await races.byId(race.id)) || race;
++ }
++ }
++ race = (await races.checkMerges(race)) || race;
++ const sideRows = new Map();
++ for (const side of race.sides || []) sideRows.set(side.slot, rowsBySlot.get(side.slot) || (side.task_id ? await anyTask(side.task_id) : null));
++ const order = (race.shown && race.shown.length ? race.shown : race.sides.map((x) => x.slot))
++ .map((slot) => (race.sides || []).find((x) => x.slot === slot)).filter(Boolean);
++ const cards = [];
++ for (const side of order) cards.push(await raceSideHtml(race, side, sideRows.get(side.slot)));
++ const match = race.match_recorded
++ ? html`<p class="run-race-match" data-match="${race.match_id}"><b>Bench match</b> <code>${race.match_id}</code> · recorded with the tests${race.merged_slot ? ' and the merge' : ''}.</p>`
++ : html`<p class="run-race-match" data-match="${race.match_id}"><b>Bench match</b> <code>${race.match_id}</code> · ${race.state === 'skipped' ? `not recorded: ${race.error || 'Codes has no bench.judgement.cast grant'}` : 'not recorded yet'}.</p>`;
++ const title = `Race: ${String(race.prompt || '').split('\n')[0].slice(0, 80)}`;
++ const body = html`<p class="crumbs"><a href="/run">Run locally</a></p>
++<h1>Race: two harnesses, one task</h1>
++<p class="lead">The same brief, repository and budget on two harnesses. The repository's own check runs on each pushed branch; the pull request a person merges is the implicit winner.</p>
++<p class="run-repo">${repoName(race.repo)}${race.ref ? html` <small>${race.ref}</small>` : ''}</p>
++<pre tabindex="0" class="run-prompt">${race.prompt}</pre>
++${match}
++<div class="run-race-sides">${cards}</div>`;
++ return page(req, res, { title, body });
++ });
++
++ /** "Race this task" pressed: two new jobs from this task's brief, on the harnesses the person named. */
++ r.post('/run/tasks/:id/race', form, async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Frun');
++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
++ if (!races) return res.status(503).type('text/plain').send('Race mode is not configured here.\n');
++ const row = await own(req, req.params.id);
++ if (!row) return res.status(404).type('text/plain').send('No such task of yours.\n');
++ const existing = await races.ofTask(row);
++ if (existing) return res.redirect(303, `/run/races/${existing.id}`);
++ const agents = [String(req.body.agent_a || '').trim(), String(req.body.agent_b || '').trim()].filter(Boolean);
++ if (agents.length > 2 || new Set(agents).size !== agents.length) return res.status(422).type('text/plain').send('Pick two different harnesses, or leave one to Codes.\n');
++ try {
++ const race = await races.startFromTask(row, { agents });
++ return res.redirect(303, `/run/races/${race.id}`);
++ } catch (e) {
++ log.warn(`[Codes] race on task ${row.id} did not start: ${e.message}`);
++ return res.status(e.status || 422).type('text/plain').send(`${e.message}\n`);
++ }
++ });
++
/**
* A task is its owner's. Someone it was shared with reads the same page without Cancel; anybody else gets the
* gate for Specific people, or "not found" when it is Only me (the task is never named to them).
@@@ -335,6 +447,7 @@ ${i.summary ? html`<p class="run-proof-summary" data-proof-summary>${i.summary}<
let row = base;
if (mine) row = await refresh(row);
const live = !TERMINAL.has(row.state);
++ const race = races ? await races.ofTask(row) : null;
// The agents' session (openvibe-shared/agent-session.js, js/run.js mounts it): live from the job's stream, and
// after the end from codes_session_lines; a task from before this change falls back to the result's old log.
const lines = live ? null : await sessionStore.linesForRow(row);
@@@ -347,12 +460,14 @@ ${i.summary ? html`<p class="run-proof-summary" data-proof-summary>${i.summary}<
<h1 class="run-repo">${repoName(row.repo)}${row.ref ? html` <small>${row.ref}</small>` : ''}</h1>
<p><span class="run-pill run-pill-${row.state}" data-state>${STATE_LABEL[row.state] || row.state}</span>${row.agent ? html` <span class="run-pill">${row.agent}</span>` : ''}</p>
${mine ? shareButton(row) : html`<p class="muted">This task was shared with you. Only its owner can change it.</p>`}
++${raceLink(race, row)}
<pre tabindex="0" class="run-prompt">${row.prompt}</pre>
${await timelineHtml(row)}
${goalHtml(row)}
${await proofHtml(row)}
${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" data-run-id="${row.id}" data-owner="${mine ? '1' : '0'}" data-title="${title}" aria-live="polite"><h2>Live</h2>${waitingLine}<ol class="run-log" data-log></ol></section>
--${mine ? html`<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : ''}` : html`${session}${resultHtml(row, { session: !!(lines && lines.length) })}`}`;
++${mine ? html`<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : ''}` : html`${session}${resultHtml(row, { session: !!(lines && lines.length) })}`}
++${!race && mine ? raceForm(row) : ''}`;
return page(req, res, { title: 'Task', body, sharedScripts: ['agent-session.js'] });
});
ddiff --git a/server/http/tasks-api.js b/server/http/tasks-api.js
iindex 6bd8cc4..2b0a596 100644
---- a/server/http/tasks-api.js
++++ b/server/http/tasks-api.js
@@@ -46,7 +46,7 @@ const FIL = /^fil_[0-9A-HJKMNP-TV-Z]{26}$/;
// The Dock's Code tab shape (the home composer's three ways, as JSON): POST /tasks dispatches on it.
const DOCK_MODES = ['create', 'openvibe', 'repo'];
--function createTasksApi({ config, store, keys, run, harnesses, actorLimits, tasks = null, modelPlans = null, fleet = null, sharing = null, conductor = null, attachments = null, log = console }) {
++function createTasksApi({ config, store, keys, run, harnesses, actorLimits, tasks = null, modelPlans = null, fleet = null, sharing = null, conductor = null, attachments = null, races = null, log = console }) {
const r = asyncRouter();
const db = store.db;
const taskStore = tasks || createRunTasks({ store, run, config, log });
@@@ -602,6 +602,28 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
return res.json(await timeline(out.row));
});
++ /**
++ * "Race this task": the same brief and repository on two harnesses, one budget each (compare-arena-ecosystem.md §5).
++ * A person names both, or one, or neither (Codes suggests two). The answer is the race and its two tasks; the
++ * tests judgement and the merged-wins judge follow on the race's own page.
++ */
++ r.post('/tasks/:id/race', guardAny('codes.task.start'), actorLimits.budget('codes.task.start'), json, async (req, res) => {
++ if (!races) return problem(req, res, 503, 'codes.race_unavailable', 'Race mode is not configured here.');
++ const row = await mine(req);
++ if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const agents = Array.isArray(b.agents) ? b.agents.map(String).filter(Boolean).slice(0, 2) : [];
++ if (new Set(agents).size !== agents.length) return problem(req, res, 422, 'codes.race_agents', 'agents must be two different harnesses');
++ if (agents.some((a) => !harnesses.list().some((h) => h.id === a))) return problem(req, res, 422, 'codes.agent_unknown', 'agents are harnesses from GET /api/v1/harnesses');
++ try {
++ const race = await races.startFromTask(row, { agents, via: req.principal.via });
++ log.info(`[Codes] race ${race.id} started from task ${row.id} by ${req.principal.via || req.principal.subject}`);
++ return res.status(201).json({ race: races.present(race), tasks: (race.sides || []).map((side) => ({ slot: side.slot, task_id: side.task_id, agent: side.agent })) });
++ } catch (e) {
++ return problem(req, res, e.status || 422, e.code || 'codes.race_failed', String((e && e.message) || 'the race could not start').slice(0, 300));
++ }
++ });
++
/**
* The running attempt stops: the outcome is "stopped" and nothing carries it on. The owner (the Dock's shape)
* gets { ok }; a service keeps the timeline its capability was built on. Another person's task is 404, never
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex ae289d2..587b8e3 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -51,6 +51,12 @@ async function startNetworkStub() {
await db.exec("INSERT INTO codes_attempts (id, task_id, n, state, agent) VALUES ($1, $2, 1, 'done', 'codex')", [`att_${ids.ulid()}`, taskId]);
await db.exec("INSERT INTO codes_task_proof (id, task_id, attempt_n, n, kind, label, ok) VALUES ($1, $2, 1, 1, 'tests', 'tests', true)", [`prf_${ids.ulid()}`, taskId]);
await db.exec("INSERT INTO codes_run_tokens (id, task_id, subject, provider, jti, expires_at) VALUES ($1, $2, $3, 'openai', $4, now())", [`rtk_${ids.ulid()}`, taskId, subject, `jti_${ids.ulid()}`]);
++ // A race and its check row name the person too: the race is exported and erased, the check cascades with it.
++ const raceId = `rce_${ids.ulid()}`;
++ await db.exec("INSERT INTO codes_races (id, subject, project_id, repo, prompt, sides, shown, state, match_id, created_at, updated_at) VALUES ($1, $2, $3, 'https://github.com/a/b', $4, '[]'::jsonb, '[]'::jsonb, 'running', $5, 1, 2)",
++ [raceId, subject, ids.newId('project'), `${subject} race`, `mat_${ids.ulid()}`]);
++ await db.exec("INSERT INTO codes_race_checks (id, race_id, slot, subject, project_id, repo, ref, state, created_at, updated_at) VALUES ($1, $2, 'a', $3, $4, 'https://github.com/a/b', 'race/a', 'queued', 1, 2)",
++ [`rch_${ids.ulid()}`, raceId, subject, ids.newId('project')]);
await db.exec("INSERT INTO codes_user_settings (subject, default_agent) VALUES ($1, 'codex')", [subject]);
await db.exec("INSERT INTO codes_model_plans (subject, scope, plan) VALUES ($1, 'account', '{\"mode\":\"single\"}'::jsonb)", [subject]);
await db.exec("INSERT INTO codes_outbox (event_id, topic, subject, payload) VALUES ($1, 'codes.task.updated', $2, '{}'::jsonb)", [`evt_${ids.ulid()}`, subject]);
@@@ -119,6 +125,10 @@ async function startNetworkStub() {
assert.ok(kn, 'knowledge.json is in the export');
assert.strictEqual(kn.content.length, 1, 'Ada’s knowledge only');
assert.match(kn.content[0].text, /learned this \(README\.md\)/);
++ const races = part.body.files.find((x) => x.name === 'races.json');
++ assert.ok(races, 'races.json is in the export');
++ assert.strictEqual(races.content.length, 1, 'Ada’s race only');
++ assert.strictEqual(races.content[0].prompt, `${ada} race`);
});
await check('a deletion erases them, keeps everyone else’s, and confirms once', async () => {
@@@ -140,6 +150,8 @@ async function startNetworkStub() {
const left = (await db.many(`SELECT ${key} FROM ${table}`)).map((r) => r[key]);
assert.deepStrictEqual(left, who, `${table}: only Bo’s row is left`);
}
++ assert.deepStrictEqual((await db.many('SELECT subject FROM codes_races')).map((r) => r.subject), [bo], 'codes_races: only Bo’s race is left');
++ assert.deepStrictEqual((await db.many('SELECT subject FROM codes_race_checks')).map((r) => r.subject), [bo], 'codes_race_checks: Ada’s check cascaded with her race');
const samples = await db.many('SELECT subject FROM telemetry_samples ORDER BY subject NULLS FIRST');
assert.deepStrictEqual(samples.map((r) => r.subject), [null, bo], 'Ada’s telemetry sample stays, without her');
});
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 0a29d06..0180886 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -41,7 +41,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
-- const built = await createApp({ config, env, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}), ...(opts.goalVerifier ? { goalVerifier: opts.goalVerifier } : {}), ...(opts.forge ? { forge: opts.forge } : {}), ...(opts.github ? { github: opts.github } : {}), ...(opts.fetchCustom ? { fetchCustom: opts.fetchCustom } : {}), ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) });
++ const built = await createApp({ config, env, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}), ...(opts.goalVerifier ? { goalVerifier: opts.goalVerifier } : {}), ...(opts.forge ? { forge: opts.forge } : {}), ...(opts.github ? { github: opts.github } : {}), ...(opts.fetchCustom ? { fetchCustom: opts.fetchCustom } : {}), ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}), ...(opts.matchRecorder ? { matchRecorder: opts.matchRecorder } : {}), ...(opts.races ? { races: opts.races } : {}), ...(opts.random ? { random: opts.random } : {}) });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/race.test.js b/test/race.test.js
nnew file mode 100644
iindex 0000000..0d65606
---- /dev/null
++++ b/test/race.test.js
@@@ -0,0 +1,192 @@
++'use strict';
++/**
++ * Race mode (server/domain/races.js, compare-arena-ecosystem.md §5): "Race this task" starts two jobs with the same
++ * brief, repository and budget on two harnesses; the repository's checks become the test_suite judgement; one
++ * bench.match@1 is recorded; the pull request a person merges adds an implicit judgement to the SAME id; and a Codes
++ * without bench.judgement.cast runs the race and skips the match with no call and no failure.
++ */
++const assert = require('assert');
++const { validate, ids } = require('openvibe-contracts');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { parseArgs } = require('../harness/job');
++const { createMatchRecorder } = require('../server/domain/races');
++const { boot, check, done } = require('./helpers/boot');
++
++const REPO = 'https://github.com/OpenVibers/OpenVibe.Codes';
++const SECRET = `fixture_${'race-events'.repeat(3)}`;
++
++(async () => {
++ const submitted = [];
++ const jobResults = new Map();
++ const run = {
++ enabled: true,
++ async submit(o) {
++ const id = ids.newId('job');
++ submitted.push({ id, ...o });
++ return { id, state: 'queued' };
++ },
++ async read(id) { return jobResults.get(id) || { state: 'queued', result: null, error: null }; },
++ async cancel() { return { state: 'cancelled' }; },
++ async streamUrl() { throw new Error('streaming is not used by a race test'); },
++ };
++ const recorded = [];
++ const recorder = {
++ enabled: true,
++ async record(match) {
++ recorded.push(JSON.parse(JSON.stringify(match)));
++ return { ok: true, created: recorded.length === 1, match };
++ },
++ };
++ const t = await boot({ run, matchRecorder: recorder, random: () => 0.6, env: { CODES_TASK_USD: '2', CODES_TASK_TOKENS: '1000', CODES_EVENTS_SECRET: SECRET } });
++ const db = t.ctx.store.db;
++ const ada = t.network.addUser('ada');
++ const project = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';
++
++ let source;
++ let race;
++ let sideRows;
++ await check('race this task starts two jobs with one budget, the same brief and repository', async () => {
++ source = await t.ctx.tasks.start({ subject: ada.subject, project, args: parseArgs(JSON.stringify({ repo: REPO, prompt: 'Fix the flaky date parser', permission: 'edit' })) });
++ const before = submitted.length;
++ race = await t.ctx.races.startFromTask(source, { agents: ['codex', 'opencode'] });
++ const started = submitted.slice(before);
++ assert.strictEqual(started.length, 2, 'two jobs started');
++ assert.deepStrictEqual(started.map((x) => x.args.agent).sort(), ['codex', 'opencode']);
++ assert.strictEqual(race.sides.length, 2);
++ assert.strictEqual(new Set(race.sides.map((x) => x.task_id)).size, 2);
++ sideRows = await db.many('SELECT * FROM run_tasks WHERE id = ANY($1) ORDER BY id', [race.sides.map((x) => x.task_id)]);
++ assert.strictEqual(sideRows.length, 2);
++ assert.strictEqual(new Set(sideRows.map((x) => x.prompt)).size, 1, 'the same prompt');
++ assert.strictEqual(new Set(sideRows.map((x) => x.repo)).size, 1, 'the same repository');
++ assert.ok(sideRows.every((x) => x.push === true && x.pull_request === true), 'both ask for a pull request');
++ const budgets = await db.many('SELECT DISTINCT budget_usd, budget_tokens FROM codes_attempts WHERE task_id = ANY($1)', [race.sides.map((x) => x.task_id)]);
++ assert.deepStrictEqual(budgets.map((b) => [b.budget_usd, b.budget_tokens]), [[2, 1000]], 'one budget for both sides');
++ });
++
++ await check('the repository’s check runs on both pushed branches', async () => {
++ const checksBefore = submitted.filter((x) => x.args && x.args.kind === 'check').length;
++ assert.strictEqual(checksBefore, 0);
++ for (const [index, side] of race.sides.entries()) {
++ const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [side.task_id]);
++ const branch = `openvibe-codes/race-test-${side.slot}`;
++ const result = {
++ ok: true, summary: `Side ${side.slot} changed the parser`, files: [{ path: 'src/date.js', added: 3, removed: 1 }],
++ push: { pushed: true, branch, branch_url: `https://github.com/OpenVibers/OpenVibe.Codes/tree/${branch}`, pull_request: `https://github.com/OpenVibers/OpenVibe.Codes/pull/${7 + index}` },
++ cost_usd: 0.1 + index * 0.05, tokens: 100 + index, resolved_model: index ? 'glm-5.3' : 'gpt-5-mini', duration_ms: 1000 + index * 500,
++ log: [{ t: 'usage', input_tokens: 60 + index, output_tokens: 40 }, { t: 'tool' }, { t: 'tool' }],
++ };
++ await t.ctx.tasks.applyEvent(row, { state: 'succeeded', job: { state: 'succeeded', result } });
++ }
++ const saved = await db.one('SELECT * FROM codes_races WHERE id = $1', [race.id]);
++ assert.strictEqual(saved.state, 'checking');
++ const checks = await db.many('SELECT * FROM codes_race_checks WHERE race_id = $1 ORDER BY slot', [race.id]);
++ assert.deepStrictEqual(checks.map((c) => c.slot), ['a', 'b']);
++ assert.ok(checks.every((c) => c.job_id && c.ref === `openvibe-codes/race-test-${c.slot}`));
++ const jobs = submitted.filter((x) => x.args && x.args.kind === 'check');
++ assert.strictEqual(jobs.length, 2, 'one check job per side');
++ assert.deepStrictEqual(jobs.map((x) => x.args.ref).sort(), ['openvibe-codes/race-test-a', 'openvibe-codes/race-test-b']);
++ });
++
++ let match;
++ await check('the match body is a bench.match@1 with both sides’ receipts and a test_suite judgement', async () => {
++ const checks = await db.many('SELECT * FROM codes_race_checks WHERE race_id = $1 ORDER BY slot', [race.id]);
++ // The check results arrive as run.job.succeeded deliveries, exactly as Run sends them.
++ for (const row of checks) {
++ const good = row.slot === 'a';
++ jobResults.set(row.job_id, {
++ state: 'succeeded', error: null,
++ result: {
++ protocol: '1.0.0', ok: good, kind: 'check', conclusion: good ? 'success' : 'failure',
++ steps: [{ name: 'test', conclusion: 'success', ms: 1 }, { name: 'lint', conclusion: good ? 'success' : 'failure', ms: 1 }],
++ log_tail: '', ms: 5,
++ },
++ });
++ const event = {
++ event_id: ids.newId('event'), event_type: 'run.job.succeeded', version: 1, source: 'run',
++ subject: { type: 'job', id: row.job_id }, visibility: 'internal',
++ payload: { job_id: row.job_id, project_id: project, requester: { type: 'user', id: ada.subject }, state: 'succeeded', timings: { finished_at: new Date().toISOString() }, error: null },
++ };
++ const body = JSON.stringify({ event, seq: 1 });
++ const delivered = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
++ assert.strictEqual(delivered.status, 200, delivered.text);
++ }
++ assert.strictEqual(recorded.length, 1, 'one match recorded');
++ match = recorded[0];
++ assert.match(match.id, /^mat_[0-9A-HJKMNP-TV-Z]{26}$/);
++ const valid = validate('bench.match@1', match);
++ assert.ok(valid.valid, JSON.stringify(valid.errors));
++ assert.deepStrictEqual([match.source, match.task.kind], ['codes', 'code']);
++ assert.strictEqual(match.slots.length, 2);
++ assert.ok(match.slots.every((s) => /^agent:(codex|opencode)\+[A-Za-z0-9._/:-]+#[a-f0-9]{16}$/.test(s.entrant)), JSON.stringify(match.slots.map((s) => s.entrant)));
++ assert.deepStrictEqual(match.slots.map((s) => s.receipts.outcome), ['succeeded', 'succeeded']);
++ assert.ok(match.slots[0].receipts.tool_calls >= 0 && match.slots[0].receipts.cost_usd > 0 && match.slots[0].receipts.wall_ms > 0);
++ const tests = match.judgements.find((j) => j.judge === 'test_suite');
++ assert.ok(tests, 'a test_suite judgement');
++ assert.deepStrictEqual(tests.scores, { a: 1, b: 0.5 });
++ assert.strictEqual(tests.winner, 'a');
++ const saved = await db.one('SELECT * FROM codes_races WHERE id = $1', [race.id]);
++ assert.strictEqual(saved.state, 'matched');
++ assert.strictEqual(saved.match_recorded, true);
++ });
++
++ await check('the merge adds an implicit judgement to the same match id', async () => {
++ const out = await t.ctx.races.handleGithubEvent({
++ event_type: 'github.pr.updated', subject: { id: 'OpenVibers/OpenVibe.Codes#8' },
++ payload: { action: 'closed', merged: true },
++ });
++ assert.strictEqual(out.outcome, 'race:merged');
++ assert.strictEqual(recorded.length, 2, 'the same match is written again');
++ assert.strictEqual(recorded[1].id, match.id, 'the same id');
++ const implicit = recorded[1].judgements.find((j) => j.judge === 'implicit');
++ assert.ok(implicit, 'an implicit judgement');
++ assert.deepStrictEqual([implicit.winner, implicit.signal], ['b', 'merged']);
++ assert.ok(validate('bench.match@1', recorded[1]).valid);
++ const again = await t.ctx.races.handleGithubEvent({
++ event_type: 'github.pr.updated', subject: { id: 'OpenVibers/OpenVibe.Codes#8' },
++ payload: { action: 'closed', merged: true },
++ });
++ assert.strictEqual(again.outcome, 'ignored:no_race', 'a redelivery adds nothing');
++ assert.strictEqual(recorded.length, 2);
++ });
++
++ await check('the run page shows both sides side by side, and the task page offers the race', async () => {
++ const page = await t.get(`/run/races/${race.id}`, { as: ada });
++ assert.strictEqual(page.status, 200, page.text.slice(0, 300));
++ assert.ok(page.text.includes('run-race-sides') && page.text.includes('Side A') && page.text.includes('Side B'), page.text.slice(0, 500));
++ assert.ok(page.text.includes(match.id), 'the match id');
++ assert.ok(page.text.includes('100% of checks passed') && page.text.includes('50% of checks passed'));
++ const sourcePage = await t.get(`/run/tasks/${source.id}`, { as: ada });
++ assert.ok(sourcePage.text.includes('Race this task') && sourcePage.text.includes(`/run/tasks/${source.id}/race`), 'the source task offers the race');
++ const sidePage = await t.get(`/run/tasks/${race.sides[0].task_id}`, { as: ada });
++ assert.ok(sidePage.text.includes(`/run/races/${race.id}`) && /side [AB] of a race/.test(sidePage.text), 'a side links to both sides');
++ assert.ok(!sidePage.text.includes('data-race-start'), 'a side does not offer to race itself');
++ const other = t.network.addUser('bo');
++ assert.strictEqual((await t.get(`/run/races/${race.id}`, { as: other })).status, 404, 'another person never sees it');
++ });
++
++ await check('no bench.judgement.cast grant: no Bench call, no failure, the race is skipped with a log line', async () => {
++ let called = false;
++ const absent = createMatchRecorder({
++ config: { oauth: { clientId: 'codes' }, networkInternalUrl: 'http://127.0.0.1:1' },
++ tokens: { authHeaders: async () => { throw new Error('invalid_scope: not granted bench.judgement.cast'); } },
++ fetchImpl: async () => { called = true; return { ok: true, json: async () => ({}) }; },
++ });
++ const out = await absent.record(match);
++ assert.strictEqual(called, false, 'Network’s refusal is not a Bench call');
++ assert.strictEqual(out.absent, true);
++ assert.match(out.reason, /bench\.judgement\.cast/);
++
++ // The race itself is unaffected: two more jobs start, and finalizing without the grant only skips the match.
++ const second = await t.ctx.races.startFromTask(source, { agents: ['codex', 'opencode'] });
++ t.ctx.races.bench.record = async () => ({ ok: false, absent: true, reason: 'Codes has no bench.judgement.cast on openvibe.bench' });
++ const before = (await db.one('SELECT * FROM codes_races WHERE id = $1', [second.id])).state;
++ assert.strictEqual(before, 'running');
++ await assert.doesNotReject(async () => { await t.ctx.races.finishRace(second.id); });
++ const saved = await db.one('SELECT * FROM codes_races WHERE id = $1', [second.id]);
++ assert.strictEqual(saved.state, 'skipped');
++ assert.match(t.logs(), /race .*match skipped/);
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exitCode = 1; });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 53505a6..145be01 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -47,6 +47,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/http/home.js': 'OpenVibe Git\'s public repository list (CODES_GIT_INTERNAL_URL, configured loopback) for the home page; no token',
'server/domain/run-client.js': 'OpenVibe.Run (CODES_RUN_URL, configured) and the Network token endpoint; job ids only, never a URL from a person',
'server/domain/bench-publisher.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured) run publishing and the Network token endpoint; artifact URLs use the configured render origin',
++ 'server/domain/races.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured) POST /api/v1/matches with Codes\' Network service token (audience openvibe.bench, bench.judgement.cast) and the Network token endpoint; the match body carries the race\'s stored repository, never a URL from a person that is fetched',
'server/http/agent-view.js': 'OpenVibe.Live (the configured Live base) with the signed-in person\'s own Network token: their own agent streams, a view ticket, the frame, pause, curtain, mute and end; stream ids from Live\'s own answer, URL-encoded; no URL from a person',
'server/domain/agent-copilot.js': 'OpenVibe.Chat (OV_CHAT_URL/config, configured) with Codes\' own service token for the room digest, answer and poll, and OpenVibe.Live for the state, copilot-ready and step calls; the stream id must be ags_<ULID> and is URL-encoded; no URL from a person or a chat line',
'server/http/run.js': 'Network projects API (configured) with the viewer\'s token, and the Run job stream URL run-client.js builds on its own base',