openvibers/openvibe.codes Commits
Placement A1: place() the task's harness, explain it, learn from the end (#198)
Changes
nnew file mode 100644@@@ -0,0 +1,36 @@++-- phase: expand++-- Placement A1 (agent plan 2026-10-30): Codes places each task's harness with openvibe-sdk/placement's place(), shows++-- the one line saying why on the task's timeline, and learns from the task's end.++--++-- codes_user_settings grows the person's two placement rules, off by default: noCloud ("Never send my code to the++-- cloud") and onlyFree ("Only free"). They are settings, not secrets, and a person without a row reads both false.++--++-- run_tasks grows the placement's one plain line and the option key it chose, so the timeline can say "Placed: …" and++-- the task's end can record one outcome for that option. placement_recorded makes the record exactly once, however++-- many times a terminal event is delivered. None of these columns holds a prompt, a key or a path: the line is the++-- SDK's fixed template, the key is the public option key.++--++-- placement_outcomes is the learned count, one row per (person, option key, step kind): two weights and a time. It++-- holds no text and no payload. Global counts are a SUM over these rows, read through a five-minute cache. The rows++-- are exported and erased with the person (server/account-data.js).++++ALTER TABLE codes_user_settings++ ADD COLUMN IF NOT EXISTS no_cloud boolean NOT NULL DEFAULT false,++ ADD COLUMN IF NOT EXISTS only_free boolean NOT NULL DEFAULT false;++++ALTER TABLE run_tasks++ ADD COLUMN IF NOT EXISTS placement_line text COLLATE "C",++ ADD COLUMN IF NOT EXISTS placement_option_key text COLLATE "C",++ ADD COLUMN IF NOT EXISTS placement_step_kind text COLLATE "C",++ ADD COLUMN IF NOT EXISTS placement_recorded boolean NOT NULL DEFAULT false;++++CREATE TABLE IF NOT EXISTS placement_outcomes (++ person_id text COLLATE "C" NOT NULL, -- usr_<ULID>++ option_key text COLLATE "C" NOT NULL, -- the SDK's optionKey(offer)++ step_kind text COLLATE "C" NOT NULL, -- the task's kind, harness.edit / harness.review++ ok_w double precision NOT NULL DEFAULT 0, -- decayed successes++ n_w double precision NOT NULL DEFAULT 0, -- decayed tries++ updated_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (person_id, option_key, step_kind)++);++CREATE INDEX IF NOT EXISTS placement_outcomes_updated ON placement_outcomes (updated_at);@@@ -16,7 +16,7 @@"helmet": "^8.1.0","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.76.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.55.0","pg": "^8.23.0"},@@@ -1165,9 +1165,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.74.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",-- "integrity": "sha512-DO9KVPnUriY/I4lX6ZEJtXaUvRDbJyqnqJxvlpl+zkNGFILpmuBXVJogNOCfK071Qci1gg7C4Iw1Xrseklcajg==",++ "version": "0.76.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.76.0",++ "integrity": "sha512-jHtxeiX1kpLUhZVKHLBcapdqgCrOaLdXaO7uw5ETCuqNnSwbm+qFG3WaL2tM2thn9QXIPvaU2BZ0//ko1QfwEg==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -31,7 +31,7 @@"helmet": "^8.1.0","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.76.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.55.0","pg": "^8.23.0"},@@@ -41,3 +41,6 @@.run-proof-head { margin: 0; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }.run-proof-summary { margin: 4px 0 0; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }.run-proof-item[data-ok="false"] { border-color: var(--danger, #e5484d); }++++/* 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; }@@@ -7,7 +7,8 @@* - each task's attempts (codes_attempts, C2), the task's proof (codes_task_proof, C8) and the events Codes took in* about it (codes_events_inbox);* - the run tokens minted for their attempts (codes_run_tokens, C3: ids and expiry, never the token);-- * - their workspace choices (codes_user_settings, C4b);++ * - their workspace choices (codes_user_settings, C4b) and their placement rules;++ * - the placement counts learned about their tasks (placement_outcomes, A1): option keys and weights, never text;* - their conductor's turns (codes_conductor_messages, C9b) and the approvals it asked them (codes_approvals);* - the events Codes sent about them (codes_outbox);* - telemetry samples that name them: the SDK covers telemetry_samples itself (telemetry.json, then subject NULL).@@@ -34,6 +35,8 @@ const TABLES = [erase: { keep: 'erased with its task-keyed rows by the service extraErase' },},{ table: 'codes_user_settings', subject: 'subject', file: 'settings.json', order: 'updated_at' },++ // Placement A1's learned counts: the person's rows leave and are erased with them. No text, no payload.++ { table: 'placement_outcomes', subject: 'person_id', file: 'placement-outcomes.json', order: 'updated_at', columns: ['person_id', 'option_key', 'step_kind', 'ok_w', 'n_w', 'updated_at'] },// What each of the person's machines reported with its heartbeat (Codes C9c): labels, numbers and times only, no// key, token or account name. Theirs to export and to erase.{ table: 'codes_machine_fleet', subject: 'subject', file: 'fleet.json', order: 'machine', columns: ['machine', 'node_id', 'name', 'agent', 'installed', 'signed_in', 'windows', 'online', 'reported_at', 'updated_at'] },@@@ -55,6 +55,9 @@ const { createMemory } = require('./domain/memory');const { createFleet } = require('./domain/fleet');const { createNotices } = require('./domain/notices');const { createUserSettings } = require('./domain/user-settings');++const { createPlacementOutcomes } = require('./domain/placement-outcomes');++const { createBenchPriors } = require('./domain/bench-priors');++const { createPlacementContext } = require('./domain/placement-context');const { createGitHub } = require('./domain/github');const { createUsage } = require('./domain/usage');const { assetVersion, send } = require('./render/layout');@@@ -95,8 +98,12 @@ async function createApp(opts = {}) {const notify = opts.notify || createNotify({ config, fetchImpl: fetchImpl || globalThis.fetch, log });const notices = createNotices({ store, config, notify, userSettings, log });const benchPublisher = createBenchPublisher({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });-- const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });-- const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks, connections, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher };++ // Placement A1: the learned counts, Bench's priors (an hour), and the context place() gets for a task.++ const placementOutcomes = opts.placementOutcomes || createPlacementOutcomes({ store, now: store.now, log });++ const benchPriors = opts.benchPriors || createBenchPriors({ config, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log, ttlMs: config.bench.priorsTtlMs });++ const placementContext = createPlacementContext({ store, config, userSettings, outcomes: placementOutcomes, priors: benchPriors, harnesses, now: store.now });++ const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });++ const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks, connections, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext };ctx.forgejo = createForgejoClient({ config, log });// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });@@@ -91,6 +91,10 @@ function load(env = process.env) {renderOrigin: trim(env.CODES_BENCH_RENDER_ORIGIN || 'https://bench-render.openvibe.services'),dailyTasks: Math.max(1, int(env.CODES_BENCH_DAILY_TASKS, 50)),maxMatrixSize: Math.max(1, int(env.CODES_BENCH_MAX_MATRIX, 100)),++ // Placement A1: Bench's quality priors, cached an hour. On in production; off in tests unless asked for, so a++ // unit test never reaches the real Bench. CODES_BENCH_PRIORS=off turns it off everywhere.++ priors: env.CODES_BENCH_PRIORS != null ? bool(env.CODES_BENCH_PRIORS) : nodeEnv !== 'test',++ priorsTtlMs: Math.max(0, int(env.CODES_BENCH_PRIORS_TTL_MS, 60 * 60_000)),},// OpenVibe.Events (ADR-033): account export and deletion arrive at POST /internal/events, signed with one of// these; run.job.* subscriptions are created at boot. The outbox relay publishes codes.task.* with thennew file mode 100644@@@ -0,0 +1,55 @@++'use strict';++++/**++ * Bench priors for placement (agent plan A1, section 6): Bench's quality score per model on the kind of task Codes is++ * placing, cached for an hour. Codes asks the SDK's Bench client for the board; a Bench that does not answer leaves++ * the priors empty, so place() uses its 0.7 default (0.5 on the floor) and the task still runs.++ *++ * createBenchPriors({ config, fetchImpl, now, log }) →++ * forTask('edit' | 'review') → { [model]: quality } one board read per kind per hour++ * enabled++ *++ * The board is public (no token) and carries `quality` in [0, 1] per model (Bench's pareto board). Codes maps Bench++ * kind codes.patch to edit and codes.review to review; a model Bench has not seen simply has no prior.++ */++const { createClient } = require('openvibe-sdk/core');++const { createBenchClient } = require('openvibe-sdk/bench');++++const TTL_MS = 60 * 60_000;++const BENCH_KIND = { edit: 'codes.patch', review: 'codes.review' };++++function createBenchPriors({ config = {}, fetchImpl = globalThis.fetch, now = Date.now, log = console, ttlMs = TTL_MS } = {}) {++ const bench = config.bench || {};++ const enabled = bench.priors !== false && !!bench.url;++ const client = enabled++ ? createBenchClient(createClient({ baseUrls: { bench: bench.url }, autoDiscover: false, fetch: fetchImpl, timeoutMs: 4000, retries: 0 }), { baseUrl: bench.url })++ : null;++ const cache = new Map(); // Bench kind → { at, scores }++++ async function scoresOf(kind) {++ const hit = cache.get(kind);++ if (hit && now() - hit.at < ttlMs) return hit.scores;++ let scores = {};++ try {++ const { board } = await client.boards.get(kind, { type: 'pareto' });++ for (const row of (board && Array.isArray(board.rows) ? board.rows : [])) {++ const q = Number(row && row.quality);++ if (row && typeof row.subject === 'string' && Number.isFinite(q)) scores[row.subject] = Math.max(0, Math.min(1, q));++ }++ } catch (e) {++ log.warn(`[Codes] Bench priors for ${kind} unavailable: ${String((e && e.message) || e).slice(0, 160)}`);++ }++ cache.set(kind, { at: now(), scores });++ return scores;++ }++++ async function forTask(task) {++ if (!enabled) return {};++ const kind = BENCH_KIND[task];++ return kind ? await scoresOf(kind) : {};++ }++++ return { enabled, forTask, BENCH_KIND };++}++++module.exports = { createBenchPriors, BENCH_KIND, TTL_MS };@@@ -224,9 +224,10 @@ function createFleet({ config = {}, store, fetchImpl = globalThis.fetch, log = cfor (const [harness, row] of Object.entries(by)) {const runner = harnessOf ? harnessOf(harness) : harness;if (!runner) continue;++ const best = (row.machines || []).find((m) => m.machine === row.machine) || (row.machines || [])[0] || null;out[runner] = {signed_in: row.signed_in, installed: row.installed, windows: row.windows,-- machine: row.machine, room: row.room, balance_usd: row.balance_usd ?? null,++ machine: row.machine, name: best ? best.name : null, room: row.room, balance_usd: row.balance_usd ?? null,};}return out;@@@ -5,7 +5,7 @@* openvibe-sdk/placement Offer, and the SDK planner picks where a coding task runs. Pure: no I/O.*/const contracts = require('openvibe-contracts');--const { plan } = require('openvibe-sdk/placement');++const { place, optionKey } = require('openvibe-sdk/placement');// A catalog row without task_capabilities derives its task kinds from the capability booleans:// edit needs capabilities.edit === true, review needs capabilities.review === true.@@@ -75,13 +75,58 @@ function requirementsFor(task, extra = {}) {return req;}--function route({ task, extra = {}, harnesses, opts = {}, now = Date.now() }) {++/**++ * Route a task with place() (placement A1): the SDK adds the person's rules, Bench priors, the learned counts and the++ * one line saying why. Everything stays pure: the caller reads the settings, the Bench priors (cached an hour) and the++ * outcomes (one read) into `context`, never this function.++ *++ * context: { person, rules, priorsByOfferId, outcomes, names, current, ownMachine, onlyAgent, floor }++ * ownMachine the person's own machine when noCloud is set: { name, agents: [harness ids] }++ * onlyAgent the agent the person chose, when the task named one++ */++function route({ task, extra = {}, harnesses, opts = {}, now = Date.now(), context = {} }) {const req = requirementsFor(task, extra);-- const offers = harnesses.list().flatMap((harness) => harnesses.agents(harness.id).map((agent) => {++ const wanted = context.onlyAgent || null;++ const names = { ...(context.names || {}) };++ let offers = harnesses.list().flatMap((harness) => harnesses.agents(harness.id).map((agent) => {const id = `${harness.id}:${agent.id || agent.model}`;return toOffer(harness, agent, { now, ...opts[id] });}));-- const result = plan(req, offers, { now });++ if (wanted) offers = offers.filter((offer) => offer.detail.id === wanted);++ // The person's own machine: noCloud turns each harness it runs into that person's own offer, so the SDK's rule++ // keeps the task there. With no such machine the offers stay external and the rule rejects every one.++ const own = context.rules && context.rules.noCloud ? context.ownMachine : null;++ if (own) {++ const runs = new Set(own.agents || []);++ offers = offers.filter((offer) => runs.has(offer.detail.id)).map((offer) => ({++ ...offer, trust: 'user-owned', owner: context.person,++ }));++ }++ // The line names the harness as the catalog does ("Codex", "Claude Code"), never its offer id, in both modes.++ for (const offer of offers) if (!names[offer.offer_id]) names[offer.offer_id] = offer.detail.name;++ // onlyFree: a paid harness is out; the person's own machine (owned capacity) and a free offer stay. The SDK's rule++ // also sets max_cost_usd 0, but Codes' catalog has no rate card to price a token with, so the price is read here.++ if (context.rules && context.rules.onlyFree) {++ offers = offers.filter((offer) => (offer.trust === 'user-owned' && offer.owner === context.person)++ || !(Number(offer.pricing && offer.pricing.marginal_usd_per_unit) > 0));++ }++ const priors = {};++ for (const offer of offers) {++ const score = context.priorsByOfferId ? context.priorsByOfferId[offer.offer_id] : undefined;++ if (score != null) priors[optionKey(offer)] = score;++ }++ const placed = place(req, offers, {++ now,++ person: context.person || null,++ step: { kind: `harness.${task}` },++ floor: context.floor,++ priors,++ outcomes: context.outcomes || [],++ rules: context.rules || {},++ names,++ current: context.current || null,++ });++ const result = placed.result;// The Contracts compat gate refused widening placement-result@1 `selected` to null: a plan with nothing selected skips the check.if (result.selected !== null) {const check = contracts.validate('platform.placement-result@1', JSON.parse(JSON.stringify(result)));@@@ -91,10 +136,14 @@ function route({ task, extra = {}, harnesses, opts = {}, now = Date.now() }) {throw error;}}++ const selectedOffer = result.selected ? offers.find((offer) => offer.offer_id === result.selected) : null;return {selected: result.selected,reasons: result.reasons,candidates: result.candidates.map((c) => ({ id: c.id, included: c.eligible, reason: c.excluded_because || null })),++ explain: { line: placed.line, ranked: placed.ranked, rejected: placed.rejected },++ option_key: selectedOffer ? optionKey(selectedOffer) : null,++ line: placed.line,};}nnew file mode 100644@@@ -0,0 +1,59 @@++'use strict';++++/**++ * What place() needs besides the offers, read outside the request's placement itself:++ *++ * - the person's two rules from Codes' settings (noCloud, onlyFree);++ * - Bench's priors for the kind, cached an hour (bench-priors.js);++ * - the learned outcomes: one read of the person's rows plus the five-minute global cache (placement-outcomes.js);++ * - the person's own machine when noCloud is set (the task's fleet, read before this, tells which harnesses it runs).++ *++ * createPlacementContext({ store, config, userSettings, outcomes, priors, now }) →++ * forTask({ subject, task, ownMachine, onlyAgent }) → the place() context++ * record({ person, key, kind, ok, at }) → learn() once, persisted++ *++ * No I/O here beyond those three reads; the placement itself is pure.++ */++function createPlacementContext({ store, config = {}, userSettings, outcomes, priors, harnesses = null, now = store.now } = {}) {++ async function forTask({ subject, task, ownMachine = null, onlyAgent = null } = {}) {++ const settings = await userSettings.get(subject);++ // The fleet is read only when the noCloud rule needs it: a task with the default rules adds no Bot read.++ const own = settings.no_cloud ? (typeof ownMachine === 'function' ? await ownMachine() : ownMachine) : null;++ const [scores, learned] = await Promise.all([++ priors ? priors.forTask(task) : Promise.resolve({}),++ outcomes ? outcomes.forPerson(subject) : Promise.resolve([]),++ ]);++ // Bench scores models; a Codes offer is a harness×agent, so the prior lands on the offer id optionKey() uses.++ const priorsByOfferId = {};++ if (harnesses && scores && Object.keys(scores).length) {++ for (const harness of harnesses.list()) {++ for (const agent of harnesses.agents(harness.id)) {++ const score = agent.model ? scores[agent.model] : undefined;++ if (score != null) priorsByOfferId[`${harness.id}:${agent.id || agent.model}`] = score;++ }++ }++ }++ return {++ person: subject,++ rules: { noCloud: !!settings.no_cloud, onlyFree: !!settings.only_free },++ priorsByOfferId,++ outcomes: learned,++ ownMachine: own,++ onlyAgent,++ now: now(),++ };++ }++++ async function record({ person, key, kind, ok, at = now() }) {++ return outcomes ? outcomes.record({ person, key, kind, ok, at }) : null;++ }++++ /** Persist what escalate() returned (it learned the failed try): never record that failure again. */++ async function recordEscalation(result, person) {++ return outcomes ? outcomes.recordAll(result && result.outcomes, person) : 0;++ }++++ return { forTask, record, recordEscalation };++}++++module.exports = { createPlacementContext };nnew file mode 100644@@@ -0,0 +1,92 @@++'use strict';++++/**++ * The learned placement counts (agent plan A1, section 3), one row per (person, option key, step kind) in++ * placement_outcomes. The row is plain data — two decayed weights and when they were last written — never text and++ * never a payload.++ *++ * createPlacementOutcomes({ store, now }) →++ * forPerson(person) the person's rows and everyone else's, for place()'s outcomes++ * record({ person, key, kind, ok, at }) learn() once and persist the resulting row++ * global() everyone's rows, cached for five minutes++ *++ * The SDK's learn() applies the 14-day half-life at write time; successOf() applies it again at read time. Global++ * counts are a SUM over the other people's rows; reading them through one query per five minutes keeps a placement++ * to one outcomes read of the person's own rows.++ */++const { learn } = require('openvibe-sdk/placement');++++const GLOBAL_TTL_MS = 5 * 60_000;++const toRow = (r) => ({++ person: r.person_id, key: r.option_key, kind: r.step_kind,++ ok_w: Number(r.ok_w) || 0, n_w: Number(r.n_w) || 0,++ updated_at: r.updated_at instanceof Date ? r.updated_at.toISOString() : String(r.updated_at),++});++++function createPlacementOutcomes({ store, now = store.now, log = console, globalTtlMs = GLOBAL_TTL_MS } = {}) {++ const db = store.db;++ let cached = { at: 0, rows: [] };++++ async function read(where, args) {++ return (await db.many(`SELECT person_id, option_key, step_kind, ok_w, n_w, updated_at FROM placement_outcomes ${where}`, args)).map(toRow);++ }++++ /** Everyone else's rows, cached: a placement reads it at most once every five minutes. */++ async function global() {++ if (cached.at && now() - cached.at < globalTtlMs) return cached.rows;++ let rows = [];++ try { rows = await read('', []); } catch (e) {++ // A placement must not fail because the learning table is briefly unreadable; it just has no priors.++ log.warn(`[Codes] placement outcomes could not be read: ${String((e && e.message) || e).slice(0, 200)}`);++ }++ cached = { at: now(), rows };++ return rows;++ }++++ /** The person's own rows plus the others', without counting the person's rows twice. */++ async function forPerson(person) {++ if (!person) return await global();++ let mine = [];++ try { mine = await read('WHERE person_id = $1', [person]); } catch (e) {++ log.warn(`[Codes] placement outcomes for ${person} could not be read: ${String((e && e.message) || e).slice(0, 200)}`);++ }++ const others = (await global()).filter((r) => r.person !== person);++ return [...mine, ...others];++ }++++ async function upsert(row) {++ await db.exec(++ `INSERT INTO placement_outcomes (person_id, option_key, step_kind, ok_w, n_w, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6::timestamptz)++ ON CONFLICT (person_id, option_key, step_kind) DO UPDATE SET++ ok_w = EXCLUDED.ok_w, n_w = EXCLUDED.n_w, updated_at = EXCLUDED.updated_at`,++ [row.person, row.key, row.kind, row.ok_w, row.n_w, row.updated_at],++ );++ cached.at = 0;++ }++++ /** learn() the outcome and persist exactly the row it returns for this person, key and kind. */++ async function record({ person, key, kind, ok, at }) {++ if (!person || !key || !kind) return null;++ const before = await read('WHERE person_id = $1 AND option_key = $2 AND step_kind = $3', [person, key, kind]);++ const next = learn(before, { person, key, kind, ok: !!ok, at });++ const row = next.find((r) => r.person === person && r.key === key && r.kind === kind);++ if (!row) return null;++ await upsert(row);++ return row;++ }++++ /**++ * Persist the counts escalate() returns, for this person, exactly as they are: escalate() already learned the++ * failed try, so a consumer must not call record() for the same failure again.++ */++ async function recordAll(rows, person) {++ const mine = (Array.isArray(rows) ? rows : []).filter((r) => r && r.person === person && r.key && r.kind);++ for (const row of mine) await upsert(row);++ return mine.length;++ }++++ return { forPerson, global, record, recordAll, GLOBAL_TTL_MS };++}++++module.exports = { createPlacementOutcomes, GLOBAL_TTL_MS };@@@ -145,7 +145,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 }) {++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 }) {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).@@@ -163,6 +163,45 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou/** An attempt's state when it ended: a limit reached is a budget-stop, never a plain failure. */const ENDED = new Set(['done', 'failed', 'stopped', 'budget-stopped', 'interrupted']);++ /**++ * The person's own machine for the placement's noCloud rule: the harnesses the fleet reports installed, and one++ * name to show. No fleet (Bot unconfigured or quiet) means no own machine, so noCloud refuses every cloud option.++ */++ function ownMachineOf(fleetRows) {++ if (!fleetRows || typeof fleetRows !== 'object') return null;++ const agents = new Set();++ let name = null;++ for (const [runner, row] of Object.entries(fleetRows)) {++ if (!row || row.installed === false) continue;++ const harness = attemptStore.HARNESS_OF[runner] || runner;++ if (!harness) continue;++ agents.add(harness);++ if (!name) name = row.name || row.machine || null;++ }++ return agents.size ? { name, agents: [...agents] } : null;++ }++++ /** Record one placement outcome at the task's end, exactly once. A stopped task is the person's own act: not counted. */++ async function recordPlacementOutcome(row) {++ if (!row || !row.placement_option_key || !row.placement_step_kind || !placementContext) return row;++ const claimed = await db.maybe(++ 'UPDATE run_tasks SET placement_recorded = true WHERE id = $1 AND placement_recorded = false RETURNING *',++ [row.id],++ );++ if (!claimed) return row;++ if (row.state === 'cancelled' || row.outcome === 'stopped') return row;++ try {++ await placementContext.record({++ person: row.subject, key: row.placement_option_key, kind: row.placement_step_kind,++ ok: row.outcome === 'done', at: store.now(),++ });++ } catch (e) {++ // A failure to learn must not turn a finished task into an error; the next task's placement uses the default.++ log.warn(`[Codes] placement outcome for task ${row.id} was not recorded: ${String((e && e.message) || e).slice(0, 200)}`);++ }++ return row;++ }++// ── The person's connections (Codes C3) ──// What they may run: the providers they keep a key for and the plans on their machines. It is read from// OpenVibe.AI (the keys) and from the machines' reports (the plans, empty until the machines report them).@@@ -491,11 +530,27 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou}const runArgs = { ...args, agent, model };const actorRefValue = actorRef && typeof actorRef === 'object' && !Array.isArray(actorRef) ? JSON.stringify(actorRef) : null;++ // Placement A1: the task's kind, its harness, and why the placer put it there. The person's rules, Bench priors++ // and learned counts are read once here; the placement itself is pure. A task whose task kind is review is++ // placed as a review, otherwise as an edit.++ const taskKind = actorRef && actorRef.bench && actorRef.bench.kind === 'review' ? 'review' : 'edit';++ let placed = null;++ if (agent && placementContext && harnesses) {++ try {++ const context = await placementContext.forTask({ subject, task: taskKind, ownMachine: async () => ownMachineOf(await fleetFor(subject)), onlyAgent: agent });++ placed = require('./harness-placement').route({ task: taskKind, harnesses, now: store.now(), context });++ } catch (e) {++ log.warn(`[Codes] task ${id}: placement could not be explained: ${String((e && e.message) || e).slice(0, 200)}`);++ }++ }++ const placementLine = placed ? placed.line : null;++ const placementKey = placed ? placed.option_key : null;++ const placementKind = placed ? `harness.${taskKind}` : null;let inserted;await store.tx(async () => {-- inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, actor_ref, created_at, updated_at)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $13) RETURNING *`,-- [id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, at]);++ inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, actor_ref, placement_line, placement_option_key, placement_step_kind, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $14, $15, $16, $16) RETURNING *`,++ [id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, placementLine, placementKey, placementKind, at]);await taskOutbox.emitTask(inserted, null);return inserted;});@@@ -527,6 +582,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, oureturn row;});if (notices) await notices.needs(failed, `the task could not start: ${why}`);++ await recordPlacementOutcome(failed);return failed;}await attemptStore.bind(first.attempt.id, { job_id: job.id, state: 'queued' });@@@ -631,6 +687,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouconst saved = await db.one('UPDATE run_tasks SET outcome = $2, outcome_note = $3, updated_at = $4 WHERE id = $1 RETURNING *',[row.id, done ? 'done' : 'unfinished', note, store.now()]);await notifyOutcome(saved);++ await recordPlacementOutcome(saved);return saved;}const attempts = await attemptStore.list(row.id);@@@ -645,6 +702,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouconst saved = await db.one('UPDATE run_tasks SET outcome = $2, outcome_note = $3, updated_at = $4 WHERE id = $1 RETURNING *',[row.id, outcome.state, outcome.note, store.now()]);await notifyOutcome(saved);++ await recordPlacementOutcome(saved);return saved;}if (!plan.kind) return row;@@@ -685,6 +743,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouif (left.exhausted) {const stopped = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,[row.id, budgetLeftNote(left), store.now()]);++ await recordPlacementOutcome(stopped);return { attempt: null, created: false, row: stopped, error: 'the task has no budget left' };}const { attempt, created } = await attemptStore.create({@@@ -701,6 +760,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouconst failed = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,[row.id, `the next attempt could not be prepared: ${e.message}`, store.now()]);await notifyOutcome(failed);++ await recordPlacementOutcome(failed);return { attempt, created: true, row: failed, error: e.message };}let job;@@@ -716,6 +776,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouconst failed = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,[row.id, `the next attempt could not start: ${why}`, store.now()]);await notifyOutcome(failed);++ await recordPlacementOutcome(failed);return { attempt, created: true, row: failed, error: why };}await attemptStore.bind(attempt.id, { job_id: job.id, state: 'queued' });@@@ -765,6 +826,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouif (left.exhausted) {const stopped = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,[row.id, budgetLeftNote(left), store.now()]);++ await recordPlacementOutcome(stopped);return { error: 'the task has no budget left', row: stopped };}const locked = await store.tx(async () => {@@@ -793,6 +855,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouconst failed = await db.one(`UPDATE run_tasks SET state = 'failed', error = $2, outcome = 'unfinished', outcome_note = $3, finished_at = $4::timestamptz, updated_at = $5 WHERE id = $1 RETURNING *`,[row.id, why, `the next attempt could not start: ${why}`, store.iso(), store.now()]);await notifyOutcome(failed);++ await recordPlacementOutcome(failed);return { error: why, row: failed, attempt };}await attemptStore.bind(attempt.id, { job_id: job.id, state: 'queued' });@@@ -8,7 +8,7 @@** createUserSettings({ store })* get(subject) → { default_agent, default_machine, notify_done, notify_needs, safety,-- * steps, minutes }++ * steps, minutes, no_cloud, only_free }* save(subject, patch) → the saved row, in the same shape** Only a usr_ subject is accepted: a service token has no preferences of its own to keep.@@@ -23,6 +23,8 @@ const LIMIT_MAX = 100000;const DEFAULTS = Object.freeze({default_agent: null, default_machine: null, notify_done: true, notify_needs: true, safety: 'ask',steps: null, minutes: null,++ // Placement A1: the person's rules, both off until they choose them.++ no_cloud: false, only_free: false,});/** A nullable limit from a form or an API: '' / 'off' / 0 / null mean no limit; a negative or silly number is refused. */@@@ -46,6 +48,8 @@ function present(row) {safety: SAFETY.includes(row.safety) ? row.safety : 'ask',steps: row.steps == null ? null : Number(row.steps),minutes: row.minutes == null ? null : Number(row.minutes),++ no_cloud: row.no_cloud === true,++ only_free: row.only_free === true,};}@@@ -79,6 +83,9 @@ function createUserSettings({ store }) {}if (patch.notify_done !== undefined) next.notify_done = patch.notify_done !== false && patch.notify_done !== '0';if (patch.notify_needs !== undefined) next.notify_needs = patch.notify_needs !== false && patch.notify_needs !== '0';++ // Placement A1's rules: a checkbox sends '1' when set, nothing when not.++ if (patch.no_cloud !== undefined) next.no_cloud = patch.no_cloud === true || patch.no_cloud === '1';++ if (patch.only_free !== undefined) next.only_free = patch.only_free === true || patch.only_free === '1';if (patch.safety !== undefined) {const v = String(patch.safety);if (!SAFETY.includes(v)) throw Object.assign(new Error(`the safety level is one of ${SAFETY.join(', ')}`), { status: 422, code: 'settings.safety_unknown' });@@@ -91,12 +98,13 @@ function createUserSettings({ store }) {if (minutes !== undefined) next.minutes = minutes;await db.exec(`INSERT INTO codes_user_settings (subject, default_agent, default_machine, notify_done, notify_needs, safety,-- steps, minutes, updated_at)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())++ steps, minutes, no_cloud, only_free, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now())ON CONFLICT (subject) DO UPDATE SET default_agent = EXCLUDED.default_agent, default_machine = EXCLUDED.default_machine,notify_done = EXCLUDED.notify_done, notify_needs = EXCLUDED.notify_needs, safety = EXCLUDED.safety,-- steps = EXCLUDED.steps, minutes = EXCLUDED.minutes, updated_at = now()`,-- [subject, next.default_agent, next.default_machine, next.notify_done, next.notify_needs, next.safety, next.steps, next.minutes],++ steps = EXCLUDED.steps, minutes = EXCLUDED.minutes, no_cloud = EXCLUDED.no_cloud,++ only_free = EXCLUDED.only_free, updated_at = now()`,++ [subject, next.default_agent, next.default_machine, next.notify_done, next.notify_needs, next.safety, next.steps, next.minutes, next.no_cloud, next.only_free],);return next;}@@@ -701,6 +701,11 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-<label>Time per task ${limitSelect('minutes', settings.minutes, [[45, '45'], [90, '90'], [180, '180'], [360, '360']], 'min')}</label></div><p class="c4-hint">For every agent. The harness's own turn cap and timer follow these; No limit means none. A task that reaches a limit lands its work in one last turn and carries on with a fresh budget.</p>++<h3 class="c4-subhead">Placement</h3>++<div class="c4-checks">++ <label class="c4-check"><input type="checkbox" name="no_cloud" value="1"${settings.no_cloud ? ' checked' : ''}><span><b>Never send my code to the cloud</b><small>Keep every task on your own machine. With no machine of yours available, nothing can run it.</small></span></label>++ <label class="c4-check"><input type="checkbox" name="only_free" value="1"${settings.only_free ? ' checked' : ''}><span><b>Only free</b><small>Use free allowances and your own machine; never a paid option.</small></span></label>++</div><h3 class="c4-subhead">Notifications</h3><div class="c4-checks"><label class="c4-check"><input type="checkbox" name="notify_done" value="1"${settings.notify_done ? ' checked' : ''}><span><b>A task finishes</b><small>Tell me when it ends, with its outcome.</small></span></label>@@@ -722,6 +727,8 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-default_machine: b.default_machine === undefined ? undefined : b.default_machine,notify_done: b.notify_done === '1',notify_needs: b.notify_needs === '1',++ no_cloud: b.no_cloud === '1',++ only_free: b.only_free === '1',...(b.safety ? { safety: b.safety } : {}),steps: b.steps === undefined ? undefined : b.steps,minutes: b.minutes === undefined ? undefined : b.minutes,@@@ -208,6 +208,7 @@ ${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.durat: '';return html`<section class="run-timeline" data-timeline data-attempts="${attempts.length}"><h2>The attempts</h2>++${row.placement_line ? html`<p class="run-placed" data-placement>Placed: ${row.placement_line}</p>` : ''}<ol class="run-attempts">${attempts.map((a) => html`<li class="run-attempt" data-attempt="${a.n}" data-attempt-id="${a.id}" data-state="${a.state}" data-reason="${a.reason || ''}" data-kind="${a.kind || 'first'}" data-agent="${a.agent || ''}"><p class="run-attempt-head"><b>Attempt ${a.n}</b>${a.agent ? html` · ${a.agent}` : ''} <span class="run-pill run-pill-${a.state}">${a.state}</span></p><p class="run-attempt-why" data-why>${a.why || ''}</p>@@@ -38,6 +38,11 @@ const rows = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'server', 'daconst agents = rows.reduce((n, row) => n + row.agents.length, 0);assert.strictEqual(body.candidates.length, agents);assert.ok(body.candidates.some((c) => c.id === body.selected && c.included === true));++ // Placement A1: the API explains the pick beside today's fields.++ assert.strictEqual(typeof body.explain.line, 'string');++ assert.ok(body.explain.line.length > 0);++ assert.ok(Array.isArray(body.explain.ranked));++ assert.ok(Array.isArray(body.explain.rejected));});await check('the router can select each of the harnesses the engine adapters added', async () => {nnew file mode 100644@@@ -0,0 +1,177 @@++'use strict';++/**++ * Placement A1 in Codes: place() explains where a task runs, the person's two rules change the answer, the line is++ * first on the task's timeline, the outcome is learned once at the end, a cancelled task is not counted, and the++ * learned table is exported and erased with the person (test/account-data.test.js covers the guard).++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const jobs = new Map();++ let counter = 0;++ const run = {++ enabled: true,++ async submit(o) {++ counter++;++ const id = `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J${String(counter).padStart(2, '0')}`;++ jobs.set(id, { id, state: 'queued', args: o.args });++ return { id, state: 'queued' };++ },++ async read(jobId) { return jobs.get(jobId) || { id: jobId, state: 'queued' }; },++ async cancel(jobId) { const j = jobs.get(jobId); if (j) j.state = 'cancelled'; return j; },++ async streamUrl() { throw new Error('not used'); },++ };++ const t = await boot({ run });++ const db = t.ctx.store.db;++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++ const project = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';++ const CAPS = ['codes.task.start', 'codes.task.read', 'codes.task.cancel'];++ const actor = t.network.mintService('svc:actor', 'openvibe.codes', CAPS);++ const call = async (method, path, { token = actor, subject = ada.subject, body } = {}) => {++ const headers = { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(subject ? { 'X-OV-Subject': subject } : {}) };++ if (body !== undefined) headers['Content-Type'] = 'application/json';++ const res = await fetch(`${t.base}/api/v1${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });++ return { status: res.status, json: await res.json().catch(() => null) };++ };++ const startTask = async (subject, agent = null) => {++ const r = await call('POST', '/tasks', { subject, body: {++ repo: 'https://github.com/octocat/Hello-World', prompt: 'Fix the parser and add a test for it.',++ ...(agent ? { agent } : {}), idempotency_key: `act_${Math.random().toString(36).slice(2, 10)}`,++ } });++ assert.strictEqual(r.status, 201, JSON.stringify(r.json));++ return r.json.task;++ };++ // The machine the fleet reports, and the agent it runs (a real fleet row, as Bot's answer would carry).++ t.bot.addMachine({ id: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X', node_id: 'nod_01J8Z4M2Q0R7T9YV3K6N8P1W2X', name: 'ada-laptop', online: true,++ fleet: [{ agent: 'codex', installed: true, signed_in: true, windows: [] }] });++ // A person starts a service's task in the project of their latest task: seed one for each.++ for (const [i, subject] of [[1, ada.subject], [2, bo.subject]]) {++ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, prompt, agent, state, created_at, updated_at)++ VALUES ($1, $2, $3, 'https://github.com/a/b', 'seed', 'codex', 'succeeded', $4, $4)`,++ [`tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2S${i}`, subject, project, i]);++ }++++ const endAttempt = async (taskId, state = 'succeeded') => {++ const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [taskId]);++ const job = { id: row.job_id, state, result: state === 'succeeded' ? { ok: true, summary: 'done' } : null, error: null };++ const updated = await t.ctx.tasks.applyEvent(row, { state, job });++ await t.ctx.tasks.afterEnd(updated.id);++ return await db.one('SELECT * FROM run_tasks WHERE id = $1', [taskId]);++ };++++ try {++ await check('the migration creates the learned table and the placement columns', async () => {++ const cols = await db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'placement_outcomes' ORDER BY column_name");++ assert.deepStrictEqual(cols.map((c) => c.column_name), ['n_w', 'ok_w', 'option_key', 'person_id', 'step_kind', 'updated_at']);++ const taskCols = (await db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'run_tasks'")).map((c) => c.column_name);++ for (const c of ['placement_line', 'placement_option_key', 'placement_step_kind', 'placement_recorded']) assert.ok(taskCols.includes(c), c);++ });++++ await check('a task gets the placement line, and the page shows it first', async () => {++ const task = await startTask(ada.subject, 'codex');++ const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [task.id]);++ assert.ok(row.placement_line, 'the task carries its line');++ assert.ok(row.placement_option_key, 'the task carries the option key');++ const page = await t.get(`/run/tasks/${task.id}`, { as: ada });++ assert.strictEqual(page.status, 200, page.text.slice(0, 200));++ assert.ok(page.text.includes('data-placement'), 'the timeline has a placement entry');++ assert.ok(page.text.includes(`Placed: ${row.placement_line}`), page.text.slice(0, 400));++ assert.ok(page.text.indexOf('data-placement') < page.text.indexOf('data-attempt='), 'it comes before the attempts');++ });++++ await check('the settings toggles are on the page and stored per person', async () => {++ const page = await t.get('/settings', { as: ada });++ assert.ok(page.text.includes('Never send my code to the cloud'));++ assert.ok(page.text.includes('Only free'));++ const saved = await t.get('/settings', { as: ada, method: 'POST', form: { no_cloud: '1', only_free: '1', notify_done: '1', notify_needs: '1', safety: 'ask' } });++ assert.strictEqual(saved.status, 303);++ const settings = await t.ctx.userSettings.get(ada.subject);++ assert.strictEqual(settings.no_cloud, true);++ assert.strictEqual(settings.only_free, true);++ assert.strictEqual((await t.ctx.userSettings.get(bo.subject)).no_cloud, false, 'another person keeps the default');++ });++++ await check('noCloud keeps the task on the person’s own machine; with none, the line says nothing can run it', async () => {++ const { route } = require('../server/domain/harness-placement');++ const { createHarnesses } = require('../server/domain/harnesses');++ const harnesses = createHarnesses();++ const own = route({ task: 'edit', harnesses, now: Date.parse('2026-10-30T12:00:00Z'), context: {++ person: ada.subject, rules: { noCloud: true }, ownMachine: { name: 'ada-laptop', agents: ['codex'] }, onlyAgent: 'codex',++ } });++ assert.strictEqual(own.selected, 'codex:codex-default');++ assert.strictEqual(own.explain.line, 'On Codex: your own machine, and it\'s free.');++ assert.ok(!own.explain.line.includes('codex:codex-default'), 'the line never shows a raw offer id');++ const none = route({ task: 'edit', harnesses, now: Date.parse('2026-10-30T12:00:00Z'), context: {++ person: bo.subject, rules: { noCloud: true }, ownMachine: null, onlyAgent: 'codex',++ } });++ assert.strictEqual(none.selected, null);++ assert.match(none.explain.line, /nothing can run this/i);++ assert.ok(none.explain.rejected.some((r) => /never send my repo to the cloud/i.test(r.why)), JSON.stringify(none.explain.rejected));++ // Only free: a paid harness is out; no free option means the line says nothing can run it.++ const paid = route({ task: 'edit', harnesses, now: Date.parse('2026-10-30T12:00:00Z'), context: {++ person: bo.subject, rules: { onlyFree: true }, onlyAgent: 'deepseek',++ } });++ assert.strictEqual(paid.selected, null);++ assert.match(paid.explain.line, /nothing can run this/i);++ });++++ await check('an escalation persists escalate()’s counts and is not learned again', async () => {++ const cy = t.network.addUser('cy');++ const { place, escalate } = require('openvibe-sdk/placement');++ const { toOffer, requirementsFor } = require('../server/domain/harness-placement');++ const { createHarnesses } = require('../server/domain/harnesses');++ const harnesses = createHarnesses();++ const at = Date.parse('2026-10-30T12:00:00Z');++ const req = requirementsFor('edit');++ const offers = [toOffer(harnesses.get('claude-code'), harnesses.get('claude-code').agents[0], { now: at })];++ const ctx = { now: at, person: cy.subject, step: { kind: 'harness.escalation-test' }, priors: {}, outcomes: [] };++ const first = place(req, offers, ctx);++ const next = escalate(first, { reason: 'quality' }, req, offers, ctx);++ assert.strictEqual(next.result.selected, null);++ assert.ok(next.outcomes.some((r) => r.person === cy.subject && r.n_w === 1 && r.ok_w === 0), 'escalate learned the failure');++ await t.ctx.placementContext.recordEscalation(next, cy.subject);++ const row = await db.one('SELECT * FROM placement_outcomes WHERE person_id = $1', [cy.subject]);++ assert.strictEqual(Number(row.n_w), 1);++ // Persisting the same returned set again is the same row, not a second failure.++ await t.ctx.placementContext.recordEscalation(next, cy.subject);++ assert.strictEqual(Number((await db.one('SELECT n_w FROM placement_outcomes WHERE person_id = $1', [cy.subject])).n_w), 1);++ });++++ await check('an outcome is recorded once at the end, and a cancelled task is not counted', async () => {++ await t.ctx.userSettings.save(ada.subject, { no_cloud: false, only_free: false });++ const task = await startTask(ada.subject, 'codex');++ const before = await db.one('SELECT * FROM run_tasks WHERE id = $1', [task.id]);++ const doneRow = await endAttempt(task.id, 'succeeded');++ assert.strictEqual(doneRow.outcome, 'done');++ const rows = await db.many('SELECT * FROM placement_outcomes WHERE person_id = $1', [ada.subject]);++ assert.strictEqual(rows.length, 1, 'one learned row');++ assert.strictEqual(rows[0].option_key, before.placement_option_key);++ assert.strictEqual(rows[0].step_kind, 'harness.edit');++ assert.ok(Number(rows[0].ok_w) > 0 && Number(rows[0].n_w) === 1);++ // The same ending delivered again changes nothing: placement_recorded is the fence.++ await t.ctx.tasks.afterEnd(task.id);++ const again = await db.one('SELECT * FROM placement_outcomes WHERE person_id = $1', [ada.subject]);++ assert.strictEqual(Number(again.n_w), 1, 'recorded once');++ // A stopped task is the person's own act: no new row.++ const cancelled = await startTask(bo.subject, 'codex');++ await t.ctx.tasks.stopTask(await db.one('SELECT * FROM run_tasks WHERE id = $1', [cancelled.id]));++ assert.strictEqual(Number(await db.value('SELECT count(*) FROM placement_outcomes WHERE person_id = $1', [bo.subject])), 0);++ });++++ await check('the learned table is exported and erased with the person', async () => {++ const accountData = require('../server/account-data').create({ db, log: t.ctx.log });++ const part = await accountData.exportPart(ada.subject);++ const file = part.files.find((f) => f.name === 'placement-outcomes.json');++ assert.ok(file, JSON.stringify(part.files.map((f) => f.name)));++ assert.strictEqual(file.content.length, 1);++ assert.ok(file.content[0].option_key);++ await accountData.erase([ada.subject]);++ assert.strictEqual(Number(await db.value('SELECT count(*) FROM placement_outcomes WHERE person_id = $1', [ada.subject])), 0);++ });++ } finally {++ await t.close();++ }++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+-- phase: expand
+-- Placement A1 (agent plan 2026-10-30): Codes places each task's harness with openvibe-sdk/placement's place(), shows
+-- the one line saying why on the task's timeline, and learns from the task's end.
+--
+-- codes_user_settings grows the person's two placement rules, off by default: noCloud ("Never send my code to the
+-- cloud") and onlyFree ("Only free"). They are settings, not secrets, and a person without a row reads both false.
+--
+-- run_tasks grows the placement's one plain line and the option key it chose, so the timeline can say "Placed: …" and
+-- the task's end can record one outcome for that option. placement_recorded makes the record exactly once, however
+-- many times a terminal event is delivered. None of these columns holds a prompt, a key or a path: the line is the
+-- SDK's fixed template, the key is the public option key.
+--
+-- placement_outcomes is the learned count, one row per (person, option key, step kind): two weights and a time. It
+-- holds no text and no payload. Global counts are a SUM over these rows, read through a five-minute cache. The rows
+-- are exported and erased with the person (server/account-data.js).
+
+ALTER TABLE codes_user_settings
+ ADD COLUMN IF NOT EXISTS no_cloud boolean NOT NULL DEFAULT false,
+ ADD COLUMN IF NOT EXISTS only_free boolean NOT NULL DEFAULT false;
+
+ALTER TABLE run_tasks
+ ADD COLUMN IF NOT EXISTS placement_line text COLLATE "C",
+ ADD COLUMN IF NOT EXISTS placement_option_key text COLLATE "C",
+ ADD COLUMN IF NOT EXISTS placement_step_kind text COLLATE "C",
+ ADD COLUMN IF NOT EXISTS placement_recorded boolean NOT NULL DEFAULT false;
+
+CREATE TABLE IF NOT EXISTS placement_outcomes (
+ person_id text COLLATE "C" NOT NULL, -- usr_<ULID>
+ option_key text COLLATE "C" NOT NULL, -- the SDK's optionKey(offer)
+ step_kind text COLLATE "C" NOT NULL, -- the task's kind, harness.edit / harness.review
+ ok_w double precision NOT NULL DEFAULT 0, -- decayed successes
+ n_w double precision NOT NULL DEFAULT 0, -- decayed tries
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (person_id, option_key, step_kind)
+);
+CREATE INDEX IF NOT EXISTS placement_outcomes_updated ON placement_outcomes (updated_at);
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.76.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.55.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.55.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1165,9 +1165,9 @@
@@ -1165,9 +1165,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.74.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
- "integrity": "sha512-DO9KVPnUriY/I4lX6ZEJtXaUvRDbJyqnqJxvlpl+zkNGFILpmuBXVJogNOCfK071Qci1gg7C4Iw1Xrseklcajg==",
+ "version": "0.76.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.76.0",
+ "integrity": "sha512-jHtxeiX1kpLUhZVKHLBcapdqgCrOaLdXaO7uw5ETCuqNnSwbm+qFG3WaL2tM2thn9QXIPvaU2BZ0//ko1QfwEg==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -31,7 +31,7 @@
@@ -31,7 +31,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.76.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.55.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.55.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -41,3 +41,6 @@
@@ -41,3 +41,6 @@
.run-proof-head { margin: 0; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
.run-proof-head { margin: 0; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
.run-proof-summary { margin: 4px 0 0; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }
.run-proof-summary { margin: 4px 0 0; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }
.run-proof-item[data-ok="false"] { border-color: var(--danger, #e5484d); }
.run-proof-item[data-ok="false"] { border-color: var(--danger, #e5484d); }
+
+/* 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; }
@@ -7,7 +7,8 @@
@@ -7,7 +7,8 @@
* - each task's attempts (codes_attempts, C2), the task's proof (codes_task_proof, C8) and the events Codes took in
* - each task's attempts (codes_attempts, C2), the task's proof (codes_task_proof, C8) and the events Codes took in
* about it (codes_events_inbox);
* about it (codes_events_inbox);
* - the run tokens minted for their attempts (codes_run_tokens, C3: ids and expiry, never the token);
* - the run tokens minted for their attempts (codes_run_tokens, C3: ids and expiry, never the token);
- * - their workspace choices (codes_user_settings, C4b);
+ * - their workspace choices (codes_user_settings, C4b) and their placement rules;
+ * - the placement counts learned about their tasks (placement_outcomes, A1): option keys and weights, never text;
* - their conductor's turns (codes_conductor_messages, C9b) and the approvals it asked them (codes_approvals);
* - their conductor's turns (codes_conductor_messages, C9b) and the approvals it asked them (codes_approvals);
* - the events Codes sent about them (codes_outbox);
* - the events Codes sent about them (codes_outbox);
* - telemetry samples that name them: the SDK covers telemetry_samples itself (telemetry.json, then subject NULL).
* - telemetry samples that name them: the SDK covers telemetry_samples itself (telemetry.json, then subject NULL).
@@ -34,6 +35,8 @@ const TABLES = [
@@ -34,6 +35,8 @@ const TABLES = [
erase: { keep: 'erased with its task-keyed rows by the service extraErase' },
erase: { keep: 'erased with its task-keyed rows by the service extraErase' },
},
},
{ table: 'codes_user_settings', subject: 'subject', file: 'settings.json', order: 'updated_at' },
{ table: 'codes_user_settings', subject: 'subject', file: 'settings.json', order: 'updated_at' },
+ // Placement A1's learned counts: the person's rows leave and are erased with them. No text, no payload.
+ { table: 'placement_outcomes', subject: 'person_id', file: 'placement-outcomes.json', order: 'updated_at', columns: ['person_id', 'option_key', 'step_kind', 'ok_w', 'n_w', 'updated_at'] },
// What each of the person's machines reported with its heartbeat (Codes C9c): labels, numbers and times only, no
// What each of the person's machines reported with its heartbeat (Codes C9c): labels, numbers and times only, no
// key, token or account name. Theirs to export and to erase.
// key, token or account name. Theirs to export and to erase.
{ table: 'codes_machine_fleet', subject: 'subject', file: 'fleet.json', order: 'machine', columns: ['machine', 'node_id', 'name', 'agent', 'installed', 'signed_in', 'windows', 'online', 'reported_at', 'updated_at'] },
{ table: 'codes_machine_fleet', subject: 'subject', file: 'fleet.json', order: 'machine', columns: ['machine', 'node_id', 'name', 'agent', 'installed', 'signed_in', 'windows', 'online', 'reported_at', 'updated_at'] },
@@ -55,6 +55,9 @@ const { createMemory } = require('./domain/memory');
@@ -55,6 +55,9 @@ const { createMemory } = require('./domain/memory');
const { createFleet } = require('./domain/fleet');
const { createFleet } = require('./domain/fleet');
const { createNotices } = require('./domain/notices');
const { createNotices } = require('./domain/notices');
const { createUserSettings } = require('./domain/user-settings');
const { createUserSettings } = require('./domain/user-settings');
+const { createPlacementOutcomes } = require('./domain/placement-outcomes');
+const { createBenchPriors } = require('./domain/bench-priors');
+const { createPlacementContext } = require('./domain/placement-context');
const { createGitHub } = require('./domain/github');
const { createGitHub } = require('./domain/github');
const { createUsage } = require('./domain/usage');
const { createUsage } = require('./domain/usage');
const { assetVersion, send } = require('./render/layout');
const { assetVersion, send } = require('./render/layout');
@@ -95,8 +98,12 @@ async function createApp(opts = {}) {
@@ -95,8 +98,12 @@ async function createApp(opts = {}) {
const notify = opts.notify || createNotify({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
const notify = opts.notify || createNotify({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
const notices = createNotices({ store, config, notify, userSettings, log });
const notices = createNotices({ store, config, notify, userSettings, log });
const benchPublisher = createBenchPublisher({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
const benchPublisher = createBenchPublisher({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
- const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
- const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks, connections, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher };
+ // Placement A1: the learned counts, Bench's priors (an hour), and the context place() gets for a task.
+ const placementOutcomes = opts.placementOutcomes || createPlacementOutcomes({ store, now: store.now, log });
+ const benchPriors = opts.benchPriors || createBenchPriors({ config, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log, ttlMs: config.bench.priorsTtlMs });
+ const placementContext = createPlacementContext({ store, config, userSettings, outcomes: placementOutcomes, priors: benchPriors, harnesses, now: store.now });
+ const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
+ const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks, connections, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext };
ctx.forgejo = createForgejoClient({ config, log });
ctx.forgejo = createForgejoClient({ config, log });
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
@@ -91,6 +91,10 @@ function load(env = process.env) {
@@ -91,6 +91,10 @@ function load(env = process.env) {
renderOrigin: trim(env.CODES_BENCH_RENDER_ORIGIN || 'https://bench-render.openvibe.services'),
renderOrigin: trim(env.CODES_BENCH_RENDER_ORIGIN || 'https://bench-render.openvibe.services'),
dailyTasks: Math.max(1, int(env.CODES_BENCH_DAILY_TASKS, 50)),
dailyTasks: Math.max(1, int(env.CODES_BENCH_DAILY_TASKS, 50)),
maxMatrixSize: Math.max(1, int(env.CODES_BENCH_MAX_MATRIX, 100)),
maxMatrixSize: Math.max(1, int(env.CODES_BENCH_MAX_MATRIX, 100)),
+ // Placement A1: Bench's quality priors, cached an hour. On in production; off in tests unless asked for, so a
+ // unit test never reaches the real Bench. CODES_BENCH_PRIORS=off turns it off everywhere.
+ priors: env.CODES_BENCH_PRIORS != null ? bool(env.CODES_BENCH_PRIORS) : nodeEnv !== 'test',
+ priorsTtlMs: Math.max(0, int(env.CODES_BENCH_PRIORS_TTL_MS, 60 * 60_000)),
},
},
// OpenVibe.Events (ADR-033): account export and deletion arrive at POST /internal/events, signed with one of
// OpenVibe.Events (ADR-033): account export and deletion arrive at POST /internal/events, signed with one of
// these; run.job.* subscriptions are created at boot. The outbox relay publishes codes.task.* with the
// these; run.job.* subscriptions are created at boot. The outbox relay publishes codes.task.* with the
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+'use strict';
+
+/**
+ * Bench priors for placement (agent plan A1, section 6): Bench's quality score per model on the kind of task Codes is
+ * placing, cached for an hour. Codes asks the SDK's Bench client for the board; a Bench that does not answer leaves
+ * the priors empty, so place() uses its 0.7 default (0.5 on the floor) and the task still runs.
+ *
+ * createBenchPriors({ config, fetchImpl, now, log }) →
+ * forTask('edit' | 'review') → { [model]: quality } one board read per kind per hour
+ * enabled
+ *
+ * The board is public (no token) and carries `quality` in [0, 1] per model (Bench's pareto board). Codes maps Bench
+ * kind codes.patch to edit and codes.review to review; a model Bench has not seen simply has no prior.
+ */
+const { createClient } = require('openvibe-sdk/core');
+const { createBenchClient } = require('openvibe-sdk/bench');
+
+const TTL_MS = 60 * 60_000;
+const BENCH_KIND = { edit: 'codes.patch', review: 'codes.review' };
+
+function createBenchPriors({ config = {}, fetchImpl = globalThis.fetch, now = Date.now, log = console, ttlMs = TTL_MS } = {}) {
+ const bench = config.bench || {};
+ const enabled = bench.priors !== false && !!bench.url;
+ const client = enabled
+ ? createBenchClient(createClient({ baseUrls: { bench: bench.url }, autoDiscover: false, fetch: fetchImpl, timeoutMs: 4000, retries: 0 }), { baseUrl: bench.url })
+ : null;
+ const cache = new Map(); // Bench kind → { at, scores }
+
+ async function scoresOf(kind) {
+ const hit = cache.get(kind);
+ if (hit && now() - hit.at < ttlMs) return hit.scores;
+ let scores = {};
+ try {
+ const { board } = await client.boards.get(kind, { type: 'pareto' });
+ for (const row of (board && Array.isArray(board.rows) ? board.rows : [])) {
+ const q = Number(row && row.quality);
+ if (row && typeof row.subject === 'string' && Number.isFinite(q)) scores[row.subject] = Math.max(0, Math.min(1, q));
+ }
+ } catch (e) {
+ log.warn(`[Codes] Bench priors for ${kind} unavailable: ${String((e && e.message) || e).slice(0, 160)}`);
+ }
+ cache.set(kind, { at: now(), scores });
+ return scores;
+ }
+
+ async function forTask(task) {
+ if (!enabled) return {};
+ const kind = BENCH_KIND[task];
+ return kind ? await scoresOf(kind) : {};
+ }
+
+ return { enabled, forTask, BENCH_KIND };
+}
+
+module.exports = { createBenchPriors, BENCH_KIND, TTL_MS };
@@ -224,9 +224,10 @@ function createFleet({ config = {}, store, fetchImpl = globalThis.fetch, log = c
@@ -224,9 +224,10 @@ function createFleet({ config = {}, store, fetchImpl = globalThis.fetch, log = c
for (const [harness, row] of Object.entries(by)) {
for (const [harness, row] of Object.entries(by)) {
const runner = harnessOf ? harnessOf(harness) : harness;
const runner = harnessOf ? harnessOf(harness) : harness;
if (!runner) continue;
if (!runner) continue;
+ const best = (row.machines || []).find((m) => m.machine === row.machine) || (row.machines || [])[0] || null;
out[runner] = {
out[runner] = {
signed_in: row.signed_in, installed: row.installed, windows: row.windows,
signed_in: row.signed_in, installed: row.installed, windows: row.windows,
- machine: row.machine, room: row.room, balance_usd: row.balance_usd ?? null,
+ machine: row.machine, name: best ? best.name : null, room: row.room, balance_usd: row.balance_usd ?? null,
};
};
}
}
return out;
return out;
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* openvibe-sdk/placement Offer, and the SDK planner picks where a coding task runs. Pure: no I/O.
* openvibe-sdk/placement Offer, and the SDK planner picks where a coding task runs. Pure: no I/O.
*/
*/
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
-const { plan } = require('openvibe-sdk/placement');
+const { place, optionKey } = require('openvibe-sdk/placement');
// A catalog row without task_capabilities derives its task kinds from the capability booleans:
// A catalog row without task_capabilities derives its task kinds from the capability booleans:
// edit needs capabilities.edit === true, review needs capabilities.review === true.
// edit needs capabilities.edit === true, review needs capabilities.review === true.
@@ -75,13 +75,58 @@ function requirementsFor(task, extra = {}) {
@@ -75,13 +75,58 @@ function requirementsFor(task, extra = {}) {
return req;
return req;
}
}
-function route({ task, extra = {}, harnesses, opts = {}, now = Date.now() }) {
+/**
+ * Route a task with place() (placement A1): the SDK adds the person's rules, Bench priors, the learned counts and the
+ * one line saying why. Everything stays pure: the caller reads the settings, the Bench priors (cached an hour) and the
+ * outcomes (one read) into `context`, never this function.
+ *
+ * context: { person, rules, priorsByOfferId, outcomes, names, current, ownMachine, onlyAgent, floor }
+ * ownMachine the person's own machine when noCloud is set: { name, agents: [harness ids] }
+ * onlyAgent the agent the person chose, when the task named one
+ */
+function route({ task, extra = {}, harnesses, opts = {}, now = Date.now(), context = {} }) {
const req = requirementsFor(task, extra);
const req = requirementsFor(task, extra);
- const offers = harnesses.list().flatMap((harness) => harnesses.agents(harness.id).map((agent) => {
+ const wanted = context.onlyAgent || null;
+ const names = { ...(context.names || {}) };
+ let offers = harnesses.list().flatMap((harness) => harnesses.agents(harness.id).map((agent) => {
const id = `${harness.id}:${agent.id || agent.model}`;
const id = `${harness.id}:${agent.id || agent.model}`;
return toOffer(harness, agent, { now, ...opts[id] });
return toOffer(harness, agent, { now, ...opts[id] });
}));
}));
- const result = plan(req, offers, { now });
+ if (wanted) offers = offers.filter((offer) => offer.detail.id === wanted);
+ // The person's own machine: noCloud turns each harness it runs into that person's own offer, so the SDK's rule
+ // keeps the task there. With no such machine the offers stay external and the rule rejects every one.
+ const own = context.rules && context.rules.noCloud ? context.ownMachine : null;
+ if (own) {
+ const runs = new Set(own.agents || []);
+ offers = offers.filter((offer) => runs.has(offer.detail.id)).map((offer) => ({
+ ...offer, trust: 'user-owned', owner: context.person,
+ }));
+ }
+ // The line names the harness as the catalog does ("Codex", "Claude Code"), never its offer id, in both modes.
+ for (const offer of offers) if (!names[offer.offer_id]) names[offer.offer_id] = offer.detail.name;
+ // onlyFree: a paid harness is out; the person's own machine (owned capacity) and a free offer stay. The SDK's rule
+ // also sets max_cost_usd 0, but Codes' catalog has no rate card to price a token with, so the price is read here.
+ if (context.rules && context.rules.onlyFree) {
+ offers = offers.filter((offer) => (offer.trust === 'user-owned' && offer.owner === context.person)
+ || !(Number(offer.pricing && offer.pricing.marginal_usd_per_unit) > 0));
+ }
+ const priors = {};
+ for (const offer of offers) {
+ const score = context.priorsByOfferId ? context.priorsByOfferId[offer.offer_id] : undefined;
+ if (score != null) priors[optionKey(offer)] = score;
+ }
+ const placed = place(req, offers, {
+ now,
+ person: context.person || null,
+ step: { kind: `harness.${task}` },
+ floor: context.floor,
+ priors,
+ outcomes: context.outcomes || [],
+ rules: context.rules || {},
+ names,
+ current: context.current || null,
+ });
+ const result = placed.result;
// The Contracts compat gate refused widening placement-result@1 `selected` to null: a plan with nothing selected skips the check.
// The Contracts compat gate refused widening placement-result@1 `selected` to null: a plan with nothing selected skips the check.
if (result.selected !== null) {
if (result.selected !== null) {
const check = contracts.validate('platform.placement-result@1', JSON.parse(JSON.stringify(result)));
const check = contracts.validate('platform.placement-result@1', JSON.parse(JSON.stringify(result)));
@@ -91,10 +136,14 @@ function route({ task, extra = {}, harnesses, opts = {}, now = Date.now() }) {
@@ -91,10 +136,14 @@ function route({ task, extra = {}, harnesses, opts = {}, now = Date.now() }) {
throw error;
throw error;
}
}
}
}
+ const selectedOffer = result.selected ? offers.find((offer) => offer.offer_id === result.selected) : null;
return {
return {
selected: result.selected,
selected: result.selected,
reasons: result.reasons,
reasons: result.reasons,
candidates: result.candidates.map((c) => ({ id: c.id, included: c.eligible, reason: c.excluded_because || null })),
candidates: result.candidates.map((c) => ({ id: c.id, included: c.eligible, reason: c.excluded_because || null })),
+ explain: { line: placed.line, ranked: placed.ranked, rejected: placed.rejected },
+ option_key: selectedOffer ? optionKey(selectedOffer) : null,
+ line: placed.line,
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+'use strict';
+
+/**
+ * What place() needs besides the offers, read outside the request's placement itself:
+ *
+ * - the person's two rules from Codes' settings (noCloud, onlyFree);
+ * - Bench's priors for the kind, cached an hour (bench-priors.js);
+ * - the learned outcomes: one read of the person's rows plus the five-minute global cache (placement-outcomes.js);
+ * - the person's own machine when noCloud is set (the task's fleet, read before this, tells which harnesses it runs).
+ *
+ * createPlacementContext({ store, config, userSettings, outcomes, priors, now }) →
+ * forTask({ subject, task, ownMachine, onlyAgent }) → the place() context
+ * record({ person, key, kind, ok, at }) → learn() once, persisted
+ *
+ * No I/O here beyond those three reads; the placement itself is pure.
+ */
+function createPlacementContext({ store, config = {}, userSettings, outcomes, priors, harnesses = null, now = store.now } = {}) {
+ async function forTask({ subject, task, ownMachine = null, onlyAgent = null } = {}) {
+ const settings = await userSettings.get(subject);
+ // The fleet is read only when the noCloud rule needs it: a task with the default rules adds no Bot read.
+ const own = settings.no_cloud ? (typeof ownMachine === 'function' ? await ownMachine() : ownMachine) : null;
+ const [scores, learned] = await Promise.all([
+ priors ? priors.forTask(task) : Promise.resolve({}),
+ outcomes ? outcomes.forPerson(subject) : Promise.resolve([]),
+ ]);
+ // Bench scores models; a Codes offer is a harness×agent, so the prior lands on the offer id optionKey() uses.
+ const priorsByOfferId = {};
+ if (harnesses && scores && Object.keys(scores).length) {
+ for (const harness of harnesses.list()) {
+ for (const agent of harnesses.agents(harness.id)) {
+ const score = agent.model ? scores[agent.model] : undefined;
+ if (score != null) priorsByOfferId[`${harness.id}:${agent.id || agent.model}`] = score;
+ }
+ }
+ }
+ return {
+ person: subject,
+ rules: { noCloud: !!settings.no_cloud, onlyFree: !!settings.only_free },
+ priorsByOfferId,
+ outcomes: learned,
+ ownMachine: own,
+ onlyAgent,
+ now: now(),
+ };
+ }
+
+ async function record({ person, key, kind, ok, at = now() }) {
+ return outcomes ? outcomes.record({ person, key, kind, ok, at }) : null;
+ }
+
+ /** Persist what escalate() returned (it learned the failed try): never record that failure again. */
+ async function recordEscalation(result, person) {
+ return outcomes ? outcomes.recordAll(result && result.outcomes, person) : 0;
+ }
+
+ return { forTask, record, recordEscalation };
+}
+
+module.exports = { createPlacementContext };
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+'use strict';
+
+/**
+ * The learned placement counts (agent plan A1, section 3), one row per (person, option key, step kind) in
+ * placement_outcomes. The row is plain data — two decayed weights and when they were last written — never text and
+ * never a payload.
+ *
+ * createPlacementOutcomes({ store, now }) →
+ * forPerson(person) the person's rows and everyone else's, for place()'s outcomes
+ * record({ person, key, kind, ok, at }) learn() once and persist the resulting row
+ * global() everyone's rows, cached for five minutes
+ *
+ * The SDK's learn() applies the 14-day half-life at write time; successOf() applies it again at read time. Global
+ * counts are a SUM over the other people's rows; reading them through one query per five minutes keeps a placement
+ * to one outcomes read of the person's own rows.
+ */
+const { learn } = require('openvibe-sdk/placement');
+
+const GLOBAL_TTL_MS = 5 * 60_000;
+const toRow = (r) => ({
+ person: r.person_id, key: r.option_key, kind: r.step_kind,
+ ok_w: Number(r.ok_w) || 0, n_w: Number(r.n_w) || 0,
+ updated_at: r.updated_at instanceof Date ? r.updated_at.toISOString() : String(r.updated_at),
+});
+
+function createPlacementOutcomes({ store, now = store.now, log = console, globalTtlMs = GLOBAL_TTL_MS } = {}) {
+ const db = store.db;
+ let cached = { at: 0, rows: [] };
+
+ async function read(where, args) {
+ return (await db.many(`SELECT person_id, option_key, step_kind, ok_w, n_w, updated_at FROM placement_outcomes ${where}`, args)).map(toRow);
+ }
+
+ /** Everyone else's rows, cached: a placement reads it at most once every five minutes. */
+ async function global() {
+ if (cached.at && now() - cached.at < globalTtlMs) return cached.rows;
+ let rows = [];
+ try { rows = await read('', []); } catch (e) {
+ // A placement must not fail because the learning table is briefly unreadable; it just has no priors.
+ log.warn(`[Codes] placement outcomes could not be read: ${String((e && e.message) || e).slice(0, 200)}`);
+ }
+ cached = { at: now(), rows };
+ return rows;
+ }
+
+ /** The person's own rows plus the others', without counting the person's rows twice. */
+ async function forPerson(person) {
+ if (!person) return await global();
+ let mine = [];
+ try { mine = await read('WHERE person_id = $1', [person]); } catch (e) {
+ log.warn(`[Codes] placement outcomes for ${person} could not be read: ${String((e && e.message) || e).slice(0, 200)}`);
+ }
+ const others = (await global()).filter((r) => r.person !== person);
+ return [...mine, ...others];
+ }
+
+ async function upsert(row) {
+ await db.exec(
+ `INSERT INTO placement_outcomes (person_id, option_key, step_kind, ok_w, n_w, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6::timestamptz)
+ ON CONFLICT (person_id, option_key, step_kind) DO UPDATE SET
+ ok_w = EXCLUDED.ok_w, n_w = EXCLUDED.n_w, updated_at = EXCLUDED.updated_at`,
+ [row.person, row.key, row.kind, row.ok_w, row.n_w, row.updated_at],
+ );
+ cached.at = 0;
+ }
+
+ /** learn() the outcome and persist exactly the row it returns for this person, key and kind. */
+ async function record({ person, key, kind, ok, at }) {
+ if (!person || !key || !kind) return null;
+ const before = await read('WHERE person_id = $1 AND option_key = $2 AND step_kind = $3', [person, key, kind]);
+ const next = learn(before, { person, key, kind, ok: !!ok, at });
+ const row = next.find((r) => r.person === person && r.key === key && r.kind === kind);
+ if (!row) return null;
+ await upsert(row);
+ return row;
+ }
+
+ /**
+ * Persist the counts escalate() returns, for this person, exactly as they are: escalate() already learned the
+ * failed try, so a consumer must not call record() for the same failure again.
+ */
+ async function recordAll(rows, person) {
+ const mine = (Array.isArray(rows) ? rows : []).filter((r) => r && r.person === person && r.key && r.kind);
+ for (const row of mine) await upsert(row);
+ return mine.length;
+ }
+
+ return { forPerson, global, record, recordAll, GLOBAL_TTL_MS };
+}
+
+module.exports = { createPlacementOutcomes, GLOBAL_TTL_MS };
@@ -145,7 +145,7 @@ function usageOf(result) {
@@ -145,7 +145,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 }) {
+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 }) {
const db = store.db;
const db = store.db;
const who = (row) => ({ subject: row.subject, project: row.project_id });
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).
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
@@ -163,6 +163,45 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -163,6 +163,45 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
/** An attempt's state when it ended: a limit reached is a budget-stop, never a plain failure. */
/** An attempt's state when it ended: a limit reached is a budget-stop, never a plain failure. */
const ENDED = new Set(['done', 'failed', 'stopped', 'budget-stopped', 'interrupted']);
const ENDED = new Set(['done', 'failed', 'stopped', 'budget-stopped', 'interrupted']);
+ /**
+ * The person's own machine for the placement's noCloud rule: the harnesses the fleet reports installed, and one
+ * name to show. No fleet (Bot unconfigured or quiet) means no own machine, so noCloud refuses every cloud option.
+ */
+ function ownMachineOf(fleetRows) {
+ if (!fleetRows || typeof fleetRows !== 'object') return null;
+ const agents = new Set();
+ let name = null;
+ for (const [runner, row] of Object.entries(fleetRows)) {
+ if (!row || row.installed === false) continue;
+ const harness = attemptStore.HARNESS_OF[runner] || runner;
+ if (!harness) continue;
+ agents.add(harness);
+ if (!name) name = row.name || row.machine || null;
+ }
+ return agents.size ? { name, agents: [...agents] } : null;
+ }
+
+ /** Record one placement outcome at the task's end, exactly once. A stopped task is the person's own act: not counted. */
+ async function recordPlacementOutcome(row) {
+ if (!row || !row.placement_option_key || !row.placement_step_kind || !placementContext) return row;
+ const claimed = await db.maybe(
+ 'UPDATE run_tasks SET placement_recorded = true WHERE id = $1 AND placement_recorded = false RETURNING *',
+ [row.id],
+ );
+ if (!claimed) return row;
+ if (row.state === 'cancelled' || row.outcome === 'stopped') return row;
+ try {
+ await placementContext.record({
+ person: row.subject, key: row.placement_option_key, kind: row.placement_step_kind,
+ ok: row.outcome === 'done', at: store.now(),
+ });
+ } catch (e) {
+ // A failure to learn must not turn a finished task into an error; the next task's placement uses the default.
+ log.warn(`[Codes] placement outcome for task ${row.id} was not recorded: ${String((e && e.message) || e).slice(0, 200)}`);
+ }
+ return row;
+ }
+
// ── The person's connections (Codes C3) ──
// ── The person's connections (Codes C3) ──
// What they may run: the providers they keep a key for and the plans on their machines. It is read from
// What they may run: the providers they keep a key for and the plans on their machines. It is read from
// OpenVibe.AI (the keys) and from the machines' reports (the plans, empty until the machines report them).
// OpenVibe.AI (the keys) and from the machines' reports (the plans, empty until the machines report them).
@@ -491,11 +530,27 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -491,11 +530,27 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
}
}
const runArgs = { ...args, agent, model };
const runArgs = { ...args, agent, model };
const actorRefValue = actorRef && typeof actorRef === 'object' && !Array.isArray(actorRef) ? JSON.stringify(actorRef) : null;
const actorRefValue = actorRef && typeof actorRef === 'object' && !Array.isArray(actorRef) ? JSON.stringify(actorRef) : null;
+ // Placement A1: the task's kind, its harness, and why the placer put it there. The person's rules, Bench priors
+ // and learned counts are read once here; the placement itself is pure. A task whose task kind is review is
+ // placed as a review, otherwise as an edit.
+ const taskKind = actorRef && actorRef.bench && actorRef.bench.kind === 'review' ? 'review' : 'edit';
+ let placed = null;
+ if (agent && placementContext && harnesses) {
+ try {
+ const context = await placementContext.forTask({ subject, task: taskKind, ownMachine: async () => ownMachineOf(await fleetFor(subject)), onlyAgent: agent });
+ placed = require('./harness-placement').route({ task: taskKind, harnesses, now: store.now(), context });
+ } catch (e) {
+ log.warn(`[Codes] task ${id}: placement could not be explained: ${String((e && e.message) || e).slice(0, 200)}`);
+ }
+ }
+ const placementLine = placed ? placed.line : null;
+ const placementKey = placed ? placed.option_key : null;
+ const placementKind = placed ? `harness.${taskKind}` : null;
let inserted;
let inserted;
await store.tx(async () => {
await store.tx(async () => {
- inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, actor_ref, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $13) RETURNING *`,
- [id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, at]);
+ inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, actor_ref, placement_line, placement_option_key, placement_step_kind, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $14, $15, $16, $16) RETURNING *`,
+ [id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, placementLine, placementKey, placementKind, at]);
await taskOutbox.emitTask(inserted, null);
await taskOutbox.emitTask(inserted, null);
return inserted;
return inserted;
});
});
@@ -527,6 +582,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -527,6 +582,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
return row;
return row;
});
});
if (notices) await notices.needs(failed, `the task could not start: ${why}`);
if (notices) await notices.needs(failed, `the task could not start: ${why}`);
+ await recordPlacementOutcome(failed);
return failed;
return failed;
}
}
await attemptStore.bind(first.attempt.id, { job_id: job.id, state: 'queued' });
await attemptStore.bind(first.attempt.id, { job_id: job.id, state: 'queued' });
@@ -631,6 +687,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -631,6 +687,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const saved = await db.one('UPDATE run_tasks SET outcome = $2, outcome_note = $3, updated_at = $4 WHERE id = $1 RETURNING *',
const saved = await db.one('UPDATE run_tasks SET outcome = $2, outcome_note = $3, updated_at = $4 WHERE id = $1 RETURNING *',
[row.id, done ? 'done' : 'unfinished', note, store.now()]);
[row.id, done ? 'done' : 'unfinished', note, store.now()]);
await notifyOutcome(saved);
await notifyOutcome(saved);
+ await recordPlacementOutcome(saved);
return saved;
return saved;
}
}
const attempts = await attemptStore.list(row.id);
const attempts = await attemptStore.list(row.id);
@@ -645,6 +702,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -645,6 +702,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const saved = await db.one('UPDATE run_tasks SET outcome = $2, outcome_note = $3, updated_at = $4 WHERE id = $1 RETURNING *',
const saved = await db.one('UPDATE run_tasks SET outcome = $2, outcome_note = $3, updated_at = $4 WHERE id = $1 RETURNING *',
[row.id, outcome.state, outcome.note, store.now()]);
[row.id, outcome.state, outcome.note, store.now()]);
await notifyOutcome(saved);
await notifyOutcome(saved);
+ await recordPlacementOutcome(saved);
return saved;
return saved;
}
}
if (!plan.kind) return row;
if (!plan.kind) return row;
@@ -685,6 +743,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -685,6 +743,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
if (left.exhausted) {
if (left.exhausted) {
const stopped = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,
const stopped = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,
[row.id, budgetLeftNote(left), store.now()]);
[row.id, budgetLeftNote(left), store.now()]);
+ await recordPlacementOutcome(stopped);
return { attempt: null, created: false, row: stopped, error: 'the task has no budget left' };
return { attempt: null, created: false, row: stopped, error: 'the task has no budget left' };
}
}
const { attempt, created } = await attemptStore.create({
const { attempt, created } = await attemptStore.create({
@@ -701,6 +760,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -701,6 +760,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const failed = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,
const failed = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,
[row.id, `the next attempt could not be prepared: ${e.message}`, store.now()]);
[row.id, `the next attempt could not be prepared: ${e.message}`, store.now()]);
await notifyOutcome(failed);
await notifyOutcome(failed);
+ await recordPlacementOutcome(failed);
return { attempt, created: true, row: failed, error: e.message };
return { attempt, created: true, row: failed, error: e.message };
}
}
let job;
let job;
@@ -716,6 +776,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -716,6 +776,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const failed = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,
const failed = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,
[row.id, `the next attempt could not start: ${why}`, store.now()]);
[row.id, `the next attempt could not start: ${why}`, store.now()]);
await notifyOutcome(failed);
await notifyOutcome(failed);
+ await recordPlacementOutcome(failed);
return { attempt, created: true, row: failed, error: why };
return { attempt, created: true, row: failed, error: why };
}
}
await attemptStore.bind(attempt.id, { job_id: job.id, state: 'queued' });
await attemptStore.bind(attempt.id, { job_id: job.id, state: 'queued' });
@@ -765,6 +826,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -765,6 +826,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
if (left.exhausted) {
if (left.exhausted) {
const stopped = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,
const stopped = await db.one(`UPDATE run_tasks SET outcome = 'unfinished', outcome_note = $2, updated_at = $3 WHERE id = $1 RETURNING *`,
[row.id, budgetLeftNote(left), store.now()]);
[row.id, budgetLeftNote(left), store.now()]);
+ await recordPlacementOutcome(stopped);
return { error: 'the task has no budget left', row: stopped };
return { error: 'the task has no budget left', row: stopped };
}
}
const locked = await store.tx(async () => {
const locked = await store.tx(async () => {
@@ -793,6 +855,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -793,6 +855,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const failed = await db.one(`UPDATE run_tasks SET state = 'failed', error = $2, outcome = 'unfinished', outcome_note = $3, finished_at = $4::timestamptz, updated_at = $5 WHERE id = $1 RETURNING *`,
const failed = await db.one(`UPDATE run_tasks SET state = 'failed', error = $2, outcome = 'unfinished', outcome_note = $3, finished_at = $4::timestamptz, updated_at = $5 WHERE id = $1 RETURNING *`,
[row.id, why, `the next attempt could not start: ${why}`, store.iso(), store.now()]);
[row.id, why, `the next attempt could not start: ${why}`, store.iso(), store.now()]);
await notifyOutcome(failed);
await notifyOutcome(failed);
+ await recordPlacementOutcome(failed);
return { error: why, row: failed, attempt };
return { error: why, row: failed, attempt };
}
}
await attemptStore.bind(attempt.id, { job_id: job.id, state: 'queued' });
await attemptStore.bind(attempt.id, { job_id: job.id, state: 'queued' });
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
*
*
* createUserSettings({ store })
* createUserSettings({ store })
* get(subject) → { default_agent, default_machine, notify_done, notify_needs, safety,
* get(subject) → { default_agent, default_machine, notify_done, notify_needs, safety,
- * steps, minutes }
+ * steps, minutes, no_cloud, only_free }
* save(subject, patch) → the saved row, in the same shape
* save(subject, patch) → the saved row, in the same shape
*
*
* Only a usr_ subject is accepted: a service token has no preferences of its own to keep.
* Only a usr_ subject is accepted: a service token has no preferences of its own to keep.
@@ -23,6 +23,8 @@ const LIMIT_MAX = 100000;
@@ -23,6 +23,8 @@ const LIMIT_MAX = 100000;
const DEFAULTS = Object.freeze({
const DEFAULTS = Object.freeze({
default_agent: null, default_machine: null, notify_done: true, notify_needs: true, safety: 'ask',
default_agent: null, default_machine: null, notify_done: true, notify_needs: true, safety: 'ask',
steps: null, minutes: null,
steps: null, minutes: null,
+ // Placement A1: the person's rules, both off until they choose them.
+ no_cloud: false, only_free: false,
});
});
/** A nullable limit from a form or an API: '' / 'off' / 0 / null mean no limit; a negative or silly number is refused. */
/** A nullable limit from a form or an API: '' / 'off' / 0 / null mean no limit; a negative or silly number is refused. */
@@ -46,6 +48,8 @@ function present(row) {
@@ -46,6 +48,8 @@ function present(row) {
safety: SAFETY.includes(row.safety) ? row.safety : 'ask',
safety: SAFETY.includes(row.safety) ? row.safety : 'ask',
steps: row.steps == null ? null : Number(row.steps),
steps: row.steps == null ? null : Number(row.steps),
minutes: row.minutes == null ? null : Number(row.minutes),
minutes: row.minutes == null ? null : Number(row.minutes),
+ no_cloud: row.no_cloud === true,
+ only_free: row.only_free === true,
};
};
}
}
@@ -79,6 +83,9 @@ function createUserSettings({ store }) {
@@ -79,6 +83,9 @@ function createUserSettings({ store }) {
}
}
if (patch.notify_done !== undefined) next.notify_done = patch.notify_done !== false && patch.notify_done !== '0';
if (patch.notify_done !== undefined) next.notify_done = patch.notify_done !== false && patch.notify_done !== '0';
if (patch.notify_needs !== undefined) next.notify_needs = patch.notify_needs !== false && patch.notify_needs !== '0';
if (patch.notify_needs !== undefined) next.notify_needs = patch.notify_needs !== false && patch.notify_needs !== '0';
+ // Placement A1's rules: a checkbox sends '1' when set, nothing when not.
+ if (patch.no_cloud !== undefined) next.no_cloud = patch.no_cloud === true || patch.no_cloud === '1';
+ if (patch.only_free !== undefined) next.only_free = patch.only_free === true || patch.only_free === '1';
if (patch.safety !== undefined) {
if (patch.safety !== undefined) {
const v = String(patch.safety);
const v = String(patch.safety);
if (!SAFETY.includes(v)) throw Object.assign(new Error(`the safety level is one of ${SAFETY.join(', ')}`), { status: 422, code: 'settings.safety_unknown' });
if (!SAFETY.includes(v)) throw Object.assign(new Error(`the safety level is one of ${SAFETY.join(', ')}`), { status: 422, code: 'settings.safety_unknown' });
@@ -91,12 +98,13 @@ function createUserSettings({ store }) {
@@ -91,12 +98,13 @@ function createUserSettings({ store }) {
if (minutes !== undefined) next.minutes = minutes;
if (minutes !== undefined) next.minutes = minutes;
await db.exec(
await db.exec(
`INSERT INTO codes_user_settings (subject, default_agent, default_machine, notify_done, notify_needs, safety,
`INSERT INTO codes_user_settings (subject, default_agent, default_machine, notify_done, notify_needs, safety,
- steps, minutes, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
+ steps, minutes, no_cloud, only_free, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now())
ON CONFLICT (subject) DO UPDATE SET default_agent = EXCLUDED.default_agent, default_machine = EXCLUDED.default_machine,
ON CONFLICT (subject) DO UPDATE SET default_agent = EXCLUDED.default_agent, default_machine = EXCLUDED.default_machine,
notify_done = EXCLUDED.notify_done, notify_needs = EXCLUDED.notify_needs, safety = EXCLUDED.safety,
notify_done = EXCLUDED.notify_done, notify_needs = EXCLUDED.notify_needs, safety = EXCLUDED.safety,
- steps = EXCLUDED.steps, minutes = EXCLUDED.minutes, updated_at = now()`,
- [subject, next.default_agent, next.default_machine, next.notify_done, next.notify_needs, next.safety, next.steps, next.minutes],
+ steps = EXCLUDED.steps, minutes = EXCLUDED.minutes, no_cloud = EXCLUDED.no_cloud,
+ only_free = EXCLUDED.only_free, updated_at = now()`,
+ [subject, next.default_agent, next.default_machine, next.notify_done, next.notify_needs, next.safety, next.steps, next.minutes, next.no_cloud, next.only_free],
);
);
return next;
return next;
}
}
@@ -701,6 +701,11 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
@@ -701,6 +701,11 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
<label>Time per task ${limitSelect('minutes', settings.minutes, [[45, '45'], [90, '90'], [180, '180'], [360, '360']], 'min')}</label>
<label>Time per task ${limitSelect('minutes', settings.minutes, [[45, '45'], [90, '90'], [180, '180'], [360, '360']], 'min')}</label>
</div>
</div>
<p class="c4-hint">For every agent. The harness's own turn cap and timer follow these; No limit means none. A task that reaches a limit lands its work in one last turn and carries on with a fresh budget.</p>
<p class="c4-hint">For every agent. The harness's own turn cap and timer follow these; No limit means none. A task that reaches a limit lands its work in one last turn and carries on with a fresh budget.</p>
+<h3 class="c4-subhead">Placement</h3>
+<div class="c4-checks">
+ <label class="c4-check"><input type="checkbox" name="no_cloud" value="1"${settings.no_cloud ? ' checked' : ''}><span><b>Never send my code to the cloud</b><small>Keep every task on your own machine. With no machine of yours available, nothing can run it.</small></span></label>
+ <label class="c4-check"><input type="checkbox" name="only_free" value="1"${settings.only_free ? ' checked' : ''}><span><b>Only free</b><small>Use free allowances and your own machine; never a paid option.</small></span></label>
+</div>
<h3 class="c4-subhead">Notifications</h3>
<h3 class="c4-subhead">Notifications</h3>
<div class="c4-checks">
<div class="c4-checks">
<label class="c4-check"><input type="checkbox" name="notify_done" value="1"${settings.notify_done ? ' checked' : ''}><span><b>A task finishes</b><small>Tell me when it ends, with its outcome.</small></span></label>
<label class="c4-check"><input type="checkbox" name="notify_done" value="1"${settings.notify_done ? ' checked' : ''}><span><b>A task finishes</b><small>Tell me when it ends, with its outcome.</small></span></label>
@@ -722,6 +727,8 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
@@ -722,6 +727,8 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
default_machine: b.default_machine === undefined ? undefined : b.default_machine,
default_machine: b.default_machine === undefined ? undefined : b.default_machine,
notify_done: b.notify_done === '1',
notify_done: b.notify_done === '1',
notify_needs: b.notify_needs === '1',
notify_needs: b.notify_needs === '1',
+ no_cloud: b.no_cloud === '1',
+ only_free: b.only_free === '1',
...(b.safety ? { safety: b.safety } : {}),
...(b.safety ? { safety: b.safety } : {}),
steps: b.steps === undefined ? undefined : b.steps,
steps: b.steps === undefined ? undefined : b.steps,
minutes: b.minutes === undefined ? undefined : b.minutes,
minutes: b.minutes === undefined ? undefined : b.minutes,
@@ -208,6 +208,7 @@ ${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.durat
@@ -208,6 +208,7 @@ ${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.durat
: '';
: '';
return html`<section class="run-timeline" data-timeline data-attempts="${attempts.length}">
return html`<section class="run-timeline" data-timeline data-attempts="${attempts.length}">
<h2>The attempts</h2>
<h2>The attempts</h2>
+${row.placement_line ? html`<p class="run-placed" data-placement>Placed: ${row.placement_line}</p>` : ''}
<ol class="run-attempts">${attempts.map((a) => html`<li class="run-attempt" data-attempt="${a.n}" data-attempt-id="${a.id}" data-state="${a.state}" data-reason="${a.reason || ''}" data-kind="${a.kind || 'first'}" data-agent="${a.agent || ''}">
<ol class="run-attempts">${attempts.map((a) => html`<li class="run-attempt" data-attempt="${a.n}" data-attempt-id="${a.id}" data-state="${a.state}" data-reason="${a.reason || ''}" data-kind="${a.kind || 'first'}" data-agent="${a.agent || ''}">
<p class="run-attempt-head"><b>Attempt ${a.n}</b>${a.agent ? html` · ${a.agent}` : ''} <span class="run-pill run-pill-${a.state}">${a.state}</span></p>
<p class="run-attempt-head"><b>Attempt ${a.n}</b>${a.agent ? html` · ${a.agent}` : ''} <span class="run-pill run-pill-${a.state}">${a.state}</span></p>
<p class="run-attempt-why" data-why>${a.why || ''}</p>
<p class="run-attempt-why" data-why>${a.why || ''}</p>
@@ -38,6 +38,11 @@ const rows = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'server', 'da
@@ -38,6 +38,11 @@ const rows = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'server', 'da
const agents = rows.reduce((n, row) => n + row.agents.length, 0);
const agents = rows.reduce((n, row) => n + row.agents.length, 0);
assert.strictEqual(body.candidates.length, agents);
assert.strictEqual(body.candidates.length, agents);
assert.ok(body.candidates.some((c) => c.id === body.selected && c.included === true));
assert.ok(body.candidates.some((c) => c.id === body.selected && c.included === true));
+ // Placement A1: the API explains the pick beside today's fields.
+ assert.strictEqual(typeof body.explain.line, 'string');
+ assert.ok(body.explain.line.length > 0);
+ assert.ok(Array.isArray(body.explain.ranked));
+ assert.ok(Array.isArray(body.explain.rejected));
});
});
await check('the router can select each of the harnesses the engine adapters added', async () => {
await check('the router can select each of the harnesses the engine adapters added', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,177 @@
@@ -0,0 +1,177 @@
+'use strict';
+/**
+ * Placement A1 in Codes: place() explains where a task runs, the person's two rules change the answer, the line is
+ * first on the task's timeline, the outcome is learned once at the end, a cancelled task is not counted, and the
+ * learned table is exported and erased with the person (test/account-data.test.js covers the guard).
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const jobs = new Map();
+ let counter = 0;
+ const run = {
+ enabled: true,
+ async submit(o) {
+ counter++;
+ const id = `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J${String(counter).padStart(2, '0')}`;
+ jobs.set(id, { id, state: 'queued', args: o.args });
+ return { id, state: 'queued' };
+ },
+ async read(jobId) { return jobs.get(jobId) || { id: jobId, state: 'queued' }; },
+ async cancel(jobId) { const j = jobs.get(jobId); if (j) j.state = 'cancelled'; return j; },
+ async streamUrl() { throw new Error('not used'); },
+ };
+ const t = await boot({ run });
+ const db = t.ctx.store.db;
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+ const project = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';
+ const CAPS = ['codes.task.start', 'codes.task.read', 'codes.task.cancel'];
+ const actor = t.network.mintService('svc:actor', 'openvibe.codes', CAPS);
+ const call = async (method, path, { token = actor, subject = ada.subject, body } = {}) => {
+ const headers = { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(subject ? { 'X-OV-Subject': subject } : {}) };
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
+ const res = await fetch(`${t.base}/api/v1${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
+ return { status: res.status, json: await res.json().catch(() => null) };
+ };
+ const startTask = async (subject, agent = null) => {
+ const r = await call('POST', '/tasks', { subject, body: {
+ repo: 'https://github.com/octocat/Hello-World', prompt: 'Fix the parser and add a test for it.',
+ ...(agent ? { agent } : {}), idempotency_key: `act_${Math.random().toString(36).slice(2, 10)}`,
+ } });
+ assert.strictEqual(r.status, 201, JSON.stringify(r.json));
+ return r.json.task;
+ };
+ // The machine the fleet reports, and the agent it runs (a real fleet row, as Bot's answer would carry).
+ t.bot.addMachine({ id: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X', node_id: 'nod_01J8Z4M2Q0R7T9YV3K6N8P1W2X', name: 'ada-laptop', online: true,
+ fleet: [{ agent: 'codex', installed: true, signed_in: true, windows: [] }] });
+ // A person starts a service's task in the project of their latest task: seed one for each.
+ for (const [i, subject] of [[1, ada.subject], [2, bo.subject]]) {
+ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, prompt, agent, state, created_at, updated_at)
+ VALUES ($1, $2, $3, 'https://github.com/a/b', 'seed', 'codex', 'succeeded', $4, $4)`,
+ [`tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2S${i}`, subject, project, i]);
+ }
+
+ const endAttempt = async (taskId, state = 'succeeded') => {
+ const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [taskId]);
+ const job = { id: row.job_id, state, result: state === 'succeeded' ? { ok: true, summary: 'done' } : null, error: null };
+ const updated = await t.ctx.tasks.applyEvent(row, { state, job });
+ await t.ctx.tasks.afterEnd(updated.id);
+ return await db.one('SELECT * FROM run_tasks WHERE id = $1', [taskId]);
+ };
+
+ try {
+ await check('the migration creates the learned table and the placement columns', async () => {
+ const cols = await db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'placement_outcomes' ORDER BY column_name");
+ assert.deepStrictEqual(cols.map((c) => c.column_name), ['n_w', 'ok_w', 'option_key', 'person_id', 'step_kind', 'updated_at']);
+ const taskCols = (await db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'run_tasks'")).map((c) => c.column_name);
+ for (const c of ['placement_line', 'placement_option_key', 'placement_step_kind', 'placement_recorded']) assert.ok(taskCols.includes(c), c);
+ });
+
+ await check('a task gets the placement line, and the page shows it first', async () => {
+ const task = await startTask(ada.subject, 'codex');
+ const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [task.id]);
+ assert.ok(row.placement_line, 'the task carries its line');
+ assert.ok(row.placement_option_key, 'the task carries the option key');
+ const page = await t.get(`/run/tasks/${task.id}`, { as: ada });
+ assert.strictEqual(page.status, 200, page.text.slice(0, 200));
+ assert.ok(page.text.includes('data-placement'), 'the timeline has a placement entry');
+ assert.ok(page.text.includes(`Placed: ${row.placement_line}`), page.text.slice(0, 400));
+ assert.ok(page.text.indexOf('data-placement') < page.text.indexOf('data-attempt='), 'it comes before the attempts');
+ });
+
+ await check('the settings toggles are on the page and stored per person', async () => {
+ const page = await t.get('/settings', { as: ada });
+ assert.ok(page.text.includes('Never send my code to the cloud'));
+ assert.ok(page.text.includes('Only free'));
+ const saved = await t.get('/settings', { as: ada, method: 'POST', form: { no_cloud: '1', only_free: '1', notify_done: '1', notify_needs: '1', safety: 'ask' } });
+ assert.strictEqual(saved.status, 303);
+ const settings = await t.ctx.userSettings.get(ada.subject);
+ assert.strictEqual(settings.no_cloud, true);
+ assert.strictEqual(settings.only_free, true);
+ assert.strictEqual((await t.ctx.userSettings.get(bo.subject)).no_cloud, false, 'another person keeps the default');
+ });
+
+ await check('noCloud keeps the task on the person’s own machine; with none, the line says nothing can run it', async () => {
+ const { route } = require('../server/domain/harness-placement');
+ const { createHarnesses } = require('../server/domain/harnesses');
+ const harnesses = createHarnesses();
+ const own = route({ task: 'edit', harnesses, now: Date.parse('2026-10-30T12:00:00Z'), context: {
+ person: ada.subject, rules: { noCloud: true }, ownMachine: { name: 'ada-laptop', agents: ['codex'] }, onlyAgent: 'codex',
+ } });
+ assert.strictEqual(own.selected, 'codex:codex-default');
+ assert.strictEqual(own.explain.line, 'On Codex: your own machine, and it\'s free.');
+ assert.ok(!own.explain.line.includes('codex:codex-default'), 'the line never shows a raw offer id');
+ const none = route({ task: 'edit', harnesses, now: Date.parse('2026-10-30T12:00:00Z'), context: {
+ person: bo.subject, rules: { noCloud: true }, ownMachine: null, onlyAgent: 'codex',
+ } });
+ assert.strictEqual(none.selected, null);
+ assert.match(none.explain.line, /nothing can run this/i);
+ assert.ok(none.explain.rejected.some((r) => /never send my repo to the cloud/i.test(r.why)), JSON.stringify(none.explain.rejected));
+ // Only free: a paid harness is out; no free option means the line says nothing can run it.
+ const paid = route({ task: 'edit', harnesses, now: Date.parse('2026-10-30T12:00:00Z'), context: {
+ person: bo.subject, rules: { onlyFree: true }, onlyAgent: 'deepseek',
+ } });
+ assert.strictEqual(paid.selected, null);
+ assert.match(paid.explain.line, /nothing can run this/i);
+ });
+
+ await check('an escalation persists escalate()’s counts and is not learned again', async () => {
+ const cy = t.network.addUser('cy');
+ const { place, escalate } = require('openvibe-sdk/placement');
+ const { toOffer, requirementsFor } = require('../server/domain/harness-placement');
+ const { createHarnesses } = require('../server/domain/harnesses');
+ const harnesses = createHarnesses();
+ const at = Date.parse('2026-10-30T12:00:00Z');
+ const req = requirementsFor('edit');
+ const offers = [toOffer(harnesses.get('claude-code'), harnesses.get('claude-code').agents[0], { now: at })];
+ const ctx = { now: at, person: cy.subject, step: { kind: 'harness.escalation-test' }, priors: {}, outcomes: [] };
+ const first = place(req, offers, ctx);
+ const next = escalate(first, { reason: 'quality' }, req, offers, ctx);
+ assert.strictEqual(next.result.selected, null);
+ assert.ok(next.outcomes.some((r) => r.person === cy.subject && r.n_w === 1 && r.ok_w === 0), 'escalate learned the failure');
+ await t.ctx.placementContext.recordEscalation(next, cy.subject);
+ const row = await db.one('SELECT * FROM placement_outcomes WHERE person_id = $1', [cy.subject]);
+ assert.strictEqual(Number(row.n_w), 1);
+ // Persisting the same returned set again is the same row, not a second failure.
+ await t.ctx.placementContext.recordEscalation(next, cy.subject);
+ assert.strictEqual(Number((await db.one('SELECT n_w FROM placement_outcomes WHERE person_id = $1', [cy.subject])).n_w), 1);
+ });
+
+ await check('an outcome is recorded once at the end, and a cancelled task is not counted', async () => {
+ await t.ctx.userSettings.save(ada.subject, { no_cloud: false, only_free: false });
+ const task = await startTask(ada.subject, 'codex');
+ const before = await db.one('SELECT * FROM run_tasks WHERE id = $1', [task.id]);
+ const doneRow = await endAttempt(task.id, 'succeeded');
+ assert.strictEqual(doneRow.outcome, 'done');
+ const rows = await db.many('SELECT * FROM placement_outcomes WHERE person_id = $1', [ada.subject]);
+ assert.strictEqual(rows.length, 1, 'one learned row');
+ assert.strictEqual(rows[0].option_key, before.placement_option_key);
+ assert.strictEqual(rows[0].step_kind, 'harness.edit');
+ assert.ok(Number(rows[0].ok_w) > 0 && Number(rows[0].n_w) === 1);
+ // The same ending delivered again changes nothing: placement_recorded is the fence.
+ await t.ctx.tasks.afterEnd(task.id);
+ const again = await db.one('SELECT * FROM placement_outcomes WHERE person_id = $1', [ada.subject]);
+ assert.strictEqual(Number(again.n_w), 1, 'recorded once');
+ // A stopped task is the person's own act: no new row.
+ const cancelled = await startTask(bo.subject, 'codex');
+ await t.ctx.tasks.stopTask(await db.one('SELECT * FROM run_tasks WHERE id = $1', [cancelled.id]));
+ assert.strictEqual(Number(await db.value('SELECT count(*) FROM placement_outcomes WHERE person_id = $1', [bo.subject])), 0);
+ });
+
+ await check('the learned table is exported and erased with the person', async () => {
+ const accountData = require('../server/account-data').create({ db, log: t.ctx.log });
+ const part = await accountData.exportPart(ada.subject);
+ const file = part.files.find((f) => f.name === 'placement-outcomes.json');
+ assert.ok(file, JSON.stringify(part.files.map((f) => f.name)));
+ assert.strictEqual(file.content.length, 1);
+ assert.ok(file.content[0].option_key);
+ await accountData.erase([ada.subject]);
+ assert.strictEqual(Number(await db.value('SELECT count(*) FROM placement_outcomes WHERE person_id = $1', [ada.subject])), 0);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();