openvibers/openvibe.codes Commits

Placement A1: place() the task's harness, explain it, learn from the end (#198)

OP OpenVibers committed 13 days ago · 179514b6cd · 18 files, +592 −22

* Placement A1: place() the task's harness, explain it, learn from the end

* Merge remote-tracking branch 'origin/main' into placement-a1-codes

* Placement review: name the harness on the line, never its offer id

Changes

ddiff --git a/migrations/0020_placement_a1.sql b/migrations/0020_placement_a1.sql
nnew file mode 100644
iindex 0000000..0aaa159
---- /dev/null
++++ b/migrations/0020_placement_a1.sql
@@@ -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);
ddiff --git a/package-lock.json b/package-lock.json
iindex e673d69..ea81939 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex ac56344..8c62683 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/public/css/run.css b/public/css/run.css
iindex 73178a0..48ece93 100644
---- a/public/css/run.css
++++ b/public/css/run.css
@@@ -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; }
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 3414d35..93ff9f7 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -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'] },
ddiff --git a/server/app.js b/server/app.js
iindex 5ad469d..202c55e 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 });
ddiff --git a/server/config.js b/server/config.js
iindex fd7af96..9587ac7 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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 the
ddiff --git a/server/domain/bench-priors.js b/server/domain/bench-priors.js
nnew file mode 100644
iindex 0000000..187a43c
---- /dev/null
++++ b/server/domain/bench-priors.js
@@@ -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 };
ddiff --git a/server/domain/fleet.js b/server/domain/fleet.js
iindex 277a1d7..bb659f8 100644
---- a/server/domain/fleet.js
++++ b/server/domain/fleet.js
@@@ -224,9 +224,10 @@ function createFleet({ config = {}, store, fetchImpl = globalThis.fetch, log = c
for (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;
ddiff --git a/server/domain/harness-placement.js b/server/domain/harness-placement.js
iindex 645d515..c74b704 100644
---- a/server/domain/harness-placement.js
++++ b/server/domain/harness-placement.js
@@@ -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,
};
}
ddiff --git a/server/domain/placement-context.js b/server/domain/placement-context.js
nnew file mode 100644
iindex 0000000..7e947aa
---- /dev/null
++++ b/server/domain/placement-context.js
@@@ -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 };
ddiff --git a/server/domain/placement-outcomes.js b/server/domain/placement-outcomes.js
nnew file mode 100644
iindex 0000000..85a6752
---- /dev/null
++++ b/server/domain/placement-outcomes.js
@@@ -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 };
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex f532b4e..5dad011 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -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, ou
return 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, ou
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()]);
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, ou
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()]);
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, ou
if (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, ou
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()]);
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, ou
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()]);
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, ou
if (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, 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 *`,
[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' });
ddiff --git a/server/domain/user-settings.js b/server/domain/user-settings.js
iindex 3cf9d96..6d58a52 100644
---- a/server/domain/user-settings.js
++++ b/server/domain/user-settings.js
@@@ -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;
}
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex 8150cf3..fd9e96e 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -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,
ddiff --git a/server/http/run.js b/server/http/run.js
iindex e519ecf..bb35693 100644
---- a/server/http/run.js
++++ b/server/http/run.js
@@@ -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>
ddiff --git a/test/harnesses-api.test.js b/test/harnesses-api.test.js
iindex dcb2178..81462fb 100644
---- a/test/harnesses-api.test.js
++++ b/test/harnesses-api.test.js
@@@ -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);
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 () => {
ddiff --git a/test/placement-a1.test.js b/test/placement-a1.test.js
nnew file mode 100644
iindex 0000000..95b2b57
---- /dev/null
++++ b/test/placement-a1.test.js
@@@ -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();
++})();