openvibers/openvibe.codes Commits
Codes C9c: the fleet, the router on live quotas, notices, ship tracking (#192)
Changes
@@@ -20,6 +20,8 @@"openvibe-codes on a person's machine (bin/openvibe-codes.js, harness/): agents, route, run, runs, show, resume; installs from the release tag","openvibe-codes task (bin/task.js): a coding task on openvibe.codes as the person — start from a brief (with --name, --tier, --permission review|edit|full), list running/needs/done, follow the event stream, continue, stop","The conductor (C9b): a task whose single attempt is a long-lived Claude Code session on a machine the person chooses, with their own plan login; each message from the web or the phone is the next turn of that session, and its work is handed to the other agents through openvibe-codes mcp (codes_task_start, list, show, continue, stop, fleet, ship status, approve), with a risky call approved or denied from the phone",++ "The fleet (C9c): what each agent on the person's machines has left — installed, signed in, and every limit window with when it frees — reported by each machine's Node with its heartbeat and kept here (labels, numbers and times only); the router skips an agent whose window is full or that is not signed in, prefers a flat-price plan with room, and leaves out a pay-per-token agent whose balance is nearly gone",++ "A task with no agent named is auto: the router reads the fleet and picks, and a task no agent may take says why and needs a person; a finished or unfinished task tells the person once (Settings: notify_done, notify_needs), and a pull request's CI, merge and deploy are on the task page","Adapters for Claude Code, Codex, OpenCode, Command Code and Aider (their CLIs) and for the DeepSeek API and any OpenAI-compatible server (the harness's own agent loop with list/read/grep/write/edit/run tools, confined to the working directory)","One event stream for every agent, in Claude Code's stream-json shape, ending with exactly one result; keys and tokens redacted","Hand-offs: a failed, rate-limited, crashed or silent attempt hands the task to the next routed agent with a note (task, tools, files changed, last message, git status)",@@@ -26,6 +26,7 @@const os = require('os');const { auth, call } = require('./task');const { probe: probeLogins } = require('../engine/logins');++const { createShip } = require('../server/domain/ship');const APPROVAL_TIMEOUT_MS = Math.max(10_000, Number(process.env.APPROVAL_TIMEOUT_MS) || 15 * 60_000);const POLL_MS = 2500;@@@ -96,12 +97,12 @@ const TOOLS = [},{name: 'codes_fleet',-- description: 'The coding CLIs on this machine: installed, signed in, and their limit windows (read from the CLIs\' own files, read-only).',++ description: 'The coding agents on the owner\'s machines: installed, signed in, and every limit window with when it frees. Each Node reports its fleet with its heartbeat; this machine is read directly from the CLIs\' own files. Read-only.',inputSchema: { type: 'object', properties: {}, additionalProperties: true },},{name: 'codes_ship_status',-- description: 'A GitHub pull request\'s state: CI, merge state, title and branch. Read-only.',++ description: 'A GitHub pull request\'s state: CI, merge, and whether the service it builds is deployed (the service\'s own /release.json, as `ovhost status` reads it). Read-only.',inputSchema: { type: 'object', properties: { repo: { type: 'string' }, pr: { type: 'number', description: 'The pull request number' } }, required: ['repo', 'pr'], additionalProperties: true },},{@@@ -202,15 +203,49 @@ async function stopTask(args) {return text({ id: t.id, state: t.state, outcome: t.outcome ? t.outcome.state : null });}--async function fleet() {++/** This machine's own reading: the CLIs' files, the same answer the Node sends with its heartbeat. */++function here() {const rows = probeLogins({ home: os.homedir() });-- return text({++ return {machine: os.hostname(),agents: rows.map((r) => ({agent: r.id, installed: !!r.installed, signed_in: !!r.signed_in,version: r.version || null,windows: (r.windows || []).map((w) => ({ name: w.name, used: w.used == null ? null : w.used, limit: w.limit == null ? null : w.limit, resets_at: w.resets_at || null })),})),++ };++}++++/**++ * The fleet: every machine's latest report from Codes (which Bot answers from the Nodes' heartbeats), and this++ * machine read directly. Without a token, or when Codes cannot answer, this machine's reading is still the answer,++ * and the reason is said rather than hidden.++ */++async function fleet() {++ let local = null;++ let localProblem = null;++ try { local = here(); } catch (e) { localProblem = String(e && e.message).slice(0, 200); }++ let stored = null;++ let problem = localProblem ? { detail: `this machine could not be read: ${localProblem}` } : null;++ try {++ const c = auth();++ const answer = await call(c, 'GET', '/api/v1/fleet');++ stored = {++ machines: (answer.machines || []).map((m) => ({++ machine: m.id, node_id: m.node_id || null, name: m.name || null, online: !!m.online, reported_at: m.reported_at || null,++ agents: (m.agents || []).map((a) => ({ agent: a.agent, installed: !!a.installed, signed_in: !!a.signed_in, windows: a.windows || [] })),++ })),++ agents: answer.agents || [],++ at: answer.at || null,++ };++ if (answer.problem && !problem) problem = answer.problem;++ } catch (e) {++ problem = problem || { detail: String(e && e.message).slice(0, 300) };++ }++ return text({++ ...(local || { machine: os.hostname(), agents: [] }),++ ...(stored ? { machines: stored.machines, all_agents: stored.agents, at: stored.at } : {}),++ ...(problem ? { problem } : {}),});}@@@ -236,11 +271,18 @@ async function shipStatus(args) {const st = await get(`/repos/${gh.owner}/${gh.name}/commits/${sha}/status`);if (st.ok && st.doc) ci = ({ success: 'Passed', failure: 'Failed', error: 'Errored', pending: 'Running' })[st.doc.state] || null;}++ // The deploy is the service's own /release.json — the same signal `ovhost status` reads.++ const ship = createShip({ config: { github: { token } }, fetchImpl: fetch });++ const deploy = await ship.deployOf({ repo: `${gh.owner}/${gh.name}`, mergeCommitSha: d.merge_commit_sha || null, merged: !!d.merged });return text({repo: `${gh.owner}/${gh.name}`, pr, title: d.title || null,state: d.merged ? 'merged' : d.state === 'closed' ? 'closed' : 'open',++ merged: !!d.merged, merge_commit_sha: d.merge_commit_sha || null,draft: !!d.draft, mergeable: d.mergeable == null ? null : !!d.mergeable, mergeable_state: d.mergeable_state || null,-- ci, branch: d.head && d.head.ref ? d.head.ref : null, base: d.base && d.base.ref ? d.base.ref : null,++ ci, deploy: {++ state: deploy.state, service: deploy.service, release: deploy.release, booted_at: deploy.booted_at, detail: deploy.detail || null,++ },++ branch: d.head && d.head.ref ? d.head.ref : null, base: d.base && d.base.ref ? d.base.ref : null,});}nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++++/**++ * What an agent's account has left, read the way the portal reads it (Codes C9c).++ *++ * A plan window (the fleet) says how much of a paid plan is used. A pay-per-token agent has no window: it has a++ * balance, and DeepSeek says what is left of it at `GET https://api.deepseek.com/user/balance` — the same call the++ * portal's account card makes, with the person's own key in `~/.config/deepseek/api_key`, and the same reading of the++ * answer: `balance_infos[0].total_balance`.++ *++ * The key never leaves the machine that holds it: it goes to DeepSeek and nowhere else, and only the number comes++ * back. A key that is not there, a call that does not answer, or an answer that says nothing about a balance, is++ * "unknown" (null) — never a number of zero, which would read as "spent".++ *++ * readBalances({ home, fetchImpl, endpoint }) → { deepseek: { usd, at } } (a harness with no key is absent)++ */++++const fs = require('fs');++const os = require('os');++const path = require('path');++++/** The call the portal makes, and the file the key is in. */++const DEEPSEEK_ENDPOINT = 'https://api.deepseek.com/user/balance';++const DEEPSEEK_KEY_FILE = path.join('.config', 'deepseek', 'api_key');++++/** The harness each balance belongs to: the pay-per-token agents Codes runs. */++const ACCOUNTS = { deepseek: { endpoint: DEEPSEEK_ENDPOINT, keyFile: DEEPSEEK_KEY_FILE } };++++/**++ * The key of an account, read from the file the CLI keeps it in. Nothing else: no key file, no key.++ *++ * @returns {string|null} the trimmed key, or null when there is none++ */++function keyOf(account, { home = os.homedir() } = {}) {++ try {++ const key = fs.readFileSync(path.join(home, account.keyFile), 'utf8').trim();++ return key || null;++ } catch {++ return null;++ }++}++++/**++ * One account's balance, as the provider says it.++ *++ * @param {string} name the account's name (deepseek)++ * @param {object} opts++ * @param {string} [opts.home] the home the key file is under++ * @param {Function} [opts.fetchImpl] the fetch to use++ * @param {string} [opts.endpoint] the endpoint to call++ * @returns {Promise<{usd: number, at: string}|null>} the balance, or null when it is unknown++ */++async function readBalance(name, { home = os.homedir(), fetchImpl = globalThis.fetch, endpoint = null } = {}) {++ const account = ACCOUNTS[name];++ if (!account) return null;++ const key = keyOf(account, { home });++ if (!key) return null;++ const url = endpoint || account.endpoint;++ let doc;++ try {++ const res = await fetchImpl(url, {++ headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },++ signal: AbortSignal.timeout(8000),++ });++ if (!res.ok) return null;++ doc = await res.json().catch(() => null);++ } catch {++ return null;++ }++ const infos = doc && Array.isArray(doc.balance_infos) ? doc.balance_infos : [];++ const first = infos[0];++ if (!first || typeof first !== 'object') return null;++ const usd = Number(first.total_balance);++ if (!Number.isFinite(usd)) return null;++ return { usd, at: new Date().toISOString() };++}++++/**++ * Every account's balance this module knows: { [harness]: { usd, at } }. A harness with no key, or one that did not++ * answer, is absent — an unknown balance, which is never a reason to skip an agent.++ *++ * @param {object} opts++ * @returns {Promise<object>} the balances++ */++async function readBalances({ home = os.homedir(), fetchImpl = globalThis.fetch, endpoint = null, names = Object.keys(ACCOUNTS) } = {}) {++ const out = {};++ for (const name of names) {++ const one = await readBalance(name, { home, fetchImpl, endpoint });++ if (one) out[name] = one;++ }++ return out;++}++++module.exports = { readBalance, readBalances, keyOf, ACCOUNTS, DEEPSEEK_ENDPOINT, DEEPSEEK_KEY_FILE };@@@ -38,6 +38,77 @@ const LADDER = {/** The tiers a brief can fall into. */const TIERS = ['basic', 'research', 'standard', 'hard', 'design'];++/**++ * How full a plan's window may be and still take a new job: 95%, not 100%. A task started with 4% of a week left would++ * run out of it mid-way, and the portal's own "low" state treats a near-full window as spent (today Command Code is at++ * 96% of its week and must not be chosen).++ */++const FULL_PCT = 95;++++/**++ * A limit window is full when it has no room left, or when its reset is still ahead and it is at FULL_PCT or more.++ * `used`/`limit` and `pct` are the two shapes providers report; `resets_at` may be an ISO string or epoch ms.++ *++ * @param {object} w a window: { name, used, limit, pct, resets_at }++ * @param {number} [now] the clock++ * @returns {boolean} may a new job be started in this window?++ */++function windowFull(w, now = Date.now()) {++ if (!w || typeof w !== 'object') return false;++ const resets = w.resets_at == null || w.resets_at === '' ? null : Date.parse(String(w.resets_at));++ if (Number.isFinite(resets) && resets > 0 && resets <= now) return false; // it has freed since it was read++ let pct = Number(w.pct);++ if (!Number.isFinite(pct)) {++ const used = Number(w.used); const limit = Number(w.limit);++ pct = Number.isFinite(used) && Number.isFinite(limit) && limit > 0 ? (used / limit) * 100 : NaN;++ }++ return Number.isFinite(pct) && pct >= FULL_PCT;++}++++/**++ * The lowest balance a pay-per-token agent may take a job on: $1. DeepSeek reads its balance the way the portal reads++ * it (engine/balance.js), and a balance under a dollar would not pay for the job: the agent would stop mid-way and the++ * task would need a person.++ */++const MIN_BALANCE_USD = 1;++++/**++ * Whether a runner's balance is too low to take a job. A balance nobody read is unknown, not empty: the agent stays++ * in the ladder (the ladder worked for years without one).++ *++ * @param {object} [fleet] the fleet per runner: { [runner]: { signed_in, windows, balance_usd } }++ * @param {string} runner the runner's id++ * @param {number} [min] the lowest balance that may take a job++ * @returns {boolean} is the balance spent?++ */++function balanceLow(fleet, runner, min = MIN_BALANCE_USD) {++ const f = fleet && fleet[runner];++ if (!f) return false;++ if (f.balance_usd == null || f.balance_usd === '') return false; // unknown, not empty++ const usd = Number(f.balance_usd);++ if (!Number.isFinite(usd)) return false;++ return usd < min;++}++++/**++ * Whether a runner has room, from the fleet the person's machines reported. An agent the fleet does not name is++ * unknown, not full: the ladder keeps working before any machine has reported (and for the API-key agents, which have no++ * plan window at all).++ *++ * @param {object} [fleet] the fleet per runner: { [runner]: { signed_in, windows, balance_usd } }++ * @param {string} runner the runner's id++ * @param {number} [now] the clock++ * @returns {boolean} may this runner take a job now++ */++function roomFor(fleet, runner, now = Date.now()) {++ const f = fleet && fleet[runner];++ if (!f) return true;++ if (f.signed_in === false) return false;++ if (balanceLow(fleet, runner)) return false;++ const windows = Array.isArray(f.windows) ? f.windows : [];++ return !windows.some((w) => windowFull(w, now));++}++// The brief a launch prompt points at: "read /…/agents/jobs/x.brief.md and follow it" is a pointer, not a task, so// the job is judged by the brief it names (2026-10-29: a pin job was routed as "a short question" to a free model// because its prompt was one sentence long). The job's own brief counts too (jobs/<name>.brief.md, jobs/<name>.task.md).@@@ -170,13 +241,14 @@ function connected(runner, connections) {* @param {string} [ctx.name] the job's own name* @param {object} [ctx.ladder] the ladder to use (the portal's by default)* @param {object} [ctx.connections] the person's connections, per runner (Codes C3)++ * @param {object} [ctx.fleet] the fleet per runner: { [runner]: { signed_in, windows } } (Codes C9c)* @returns {{runner: string, model: string|null, effort: string|null, tier: string, brief: string|null, why: string}|null}*/--function pick(prompt, { cfg, health, counts, models, runs, JOBS, name, ladder, connections } = {}) {++function pick(prompt, { cfg, health, counts, models, runs, JOBS, name, ladder, connections, fleet } = {}) {const c = classify(prompt, { JOBS, name });const rec = track(runs);const reliable = (r) => { const t = rec[r]; return !t || t.done + t.failed < 4 || t.done / (t.done + t.failed) >= 0.4; };-- const usable = (r) => cfg.subagents[r] && cfg.subagents[r].enabled && (counts[r] || 0) < cfg.subagents[r].max && health[r] && health[r].state === 'ok' && reliable(r) && connected(r, connections);++ const usable = (r) => cfg.subagents[r] && cfg.subagents[r].enabled && (counts[r] || 0) < cfg.subagents[r].max && health[r] && health[r].state === 'ok' && reliable(r) && connected(r, connections) && roomFor(fleet, r);for (const [runner, model, wanted] of (ladder || LADDER)[c.tier]) {if (!usable(runner)) continue;// A model the runner no longer lists is skipped for that runner's default (lists refresh from each provider).@@@ -188,4 +260,4 @@ function pick(prompt, { cfg, health, counts, models, runs, JOBS, name, ladder, creturn null;}--module.exports = { LADDER, TIERS, classify, classifyText, pick, track, briefFor, forcedTier, fitEffort, stepUp, EFFORTS, connected };++module.exports = { LADDER, TIERS, FULL_PCT, MIN_BALANCE_USD, classify, classifyText, pick, track, briefFor, forcedTier, fitEffort, stepUp, EFFORTS, connected, windowFull, balanceLow, roomFor };nnew file mode 100644@@@ -0,0 +1,36 @@++-- phase: expand++-- Codes C9c: the fleet, and the notices a task sends once.++--++-- codes_machine_fleet is the latest state each of a person's machines reported with its heartbeat: per agent, whether++-- it is installed and signed in, and the limit windows it can read. It holds no key, token or account name (the Node++-- reports numbers and labels only), and a machine that reports again replaces its own rows.++CREATE TABLE IF NOT EXISTS codes_machine_fleet (++ subject text COLLATE "C" NOT NULL, -- usr_… the machines' owner++ machine text COLLATE "C" NOT NULL, -- the device id Run places a job on (dev_…)++ node_id text COLLATE "C", -- the Network principal (nod_…), when the machine has one++ name text COLLATE "C", -- the machine's name, as its owner gave it++ agent text COLLATE "C" NOT NULL, -- a harness id: codex, opencode, command-code, minimax-code, claude-code++ installed boolean NOT NULL DEFAULT false,++ signed_in boolean NOT NULL DEFAULT false,++ windows jsonb NOT NULL DEFAULT '[]'::jsonb, -- [{name, used, limit, pct, resets_at}], numbers and labels only++ online boolean NOT NULL DEFAULT false,++ reported_at timestamptz, -- when the machine last reported this fleet++ updated_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (subject, machine, agent)++);++CREATE INDEX IF NOT EXISTS codes_machine_fleet_subject ON codes_machine_fleet (subject, agent);++++-- codes_notices: one notification per task and state ('done', 'needs'). The unique row is what makes a notice once:++-- a task's state can be recorded again (a redelivery, a fallback read), and the person is not told twice.++CREATE TABLE IF NOT EXISTS codes_notices (++ task_id text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL, -- done | needs++ sent_at timestamptz NOT NULL DEFAULT now(),++ result text COLLATE "C",++ PRIMARY KEY (task_id, kind)++);++++-- A conductor's chosen machine, pinned on Run by its OpenVibe.Bot device id (`dev_…`, the id codes_machine_fleet stores++-- as `machine`): every turn of the session runs there, where its folder and its memory are (Run's job `node`, contracts++-- 0.250.0). NULL: not a conductor, or a conductor created without a machine — Run places the job where it fits, as before.++ALTER TABLE run_tasks ADD COLUMN IF NOT EXISTS pin text COLLATE "C";@@@ -194,6 +194,30 @@@media(max-width:760px){body.ov-workspace{padding-bottom:var(--ovnav-appbar-h,calc(58px + env(safe-area-inset-bottom,0px)))}}++++++/* C9c: the fleet. A desktop row reads across: the agent and its machine, its meters, its state. A phone row is the++ agent and its state, then the meters under it. */++.c4-fleet .c4-fleet-row{grid-template-columns:12px minmax(0,1fr) auto;padding:12px 0;align-items:center;column-gap:10px}++.c4-fleet-row .c4-row-dot{grid-row:1/3;align-self:center}++.c4-fleet-row .c4-row-label{grid-column:2;grid-row:1}++.c4-fleet-row .c4-row-note{grid-column:2;grid-row:2}++.c4-fleet-row > .c4-pill{grid-column:3;grid-row:1/3;align-self:center}++.c4-fleet-windows{grid-column:2/-1;grid-row:3;display:grid;gap:6px;margin-top:6px;min-width:0}++.c4-meter{display:grid;grid-template-columns:46px minmax(0,1fr);align-items:center;gap:3px 10px;font-size:12px;min-width:0}++.c4-meter-name{color:var(--c4-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}++.c4-meter-track{display:block;height:6px;border-radius:99px;background:var(--c4-quiet-bg);overflow:hidden}++.c4-meter-fill{display:block;height:100%;border-radius:99px;background:var(--c4-dot-ok)}++.c4-meter-fill.is-full{background:var(--c4-dot-bad)}++.c4-meter-note{grid-column:2;color:var(--c4-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}++.c4-meter-note b{color:var(--c4-ink);font-weight:650}++@media(min-width:761px){++ .c4-fleet .c4-fleet-row{grid-template-columns:12px minmax(180px,260px) minmax(0,1fr) 128px;column-gap:18px}++ .c4-fleet-windows{grid-column:3;grid-row:1/3;margin:0}++ .c4-meter{grid-template-columns:52px minmax(120px,1fr) minmax(0,auto)}++ .c4-meter-note{grid-column:3}++ .c4-fleet-row > .c4-pill{grid-column:4;justify-self:end}++}/* The narrowest phones: one control per row, and a heading over its state, so no label is cut or broken(a 320 px screen has ~290 px of text; "Opus 5.5 (default)" and "Conductor" both fit whole). */@media(max-width:380px){@@@ -204,4 +228,4 @@/* The season's leaves fall from the Frame's mark over the page (openvibe-shared/ov-mark.js, .ovm-fall, a fixed layer atz-index 10010, 120-280 px down). The page's own title stays above them: a once-a-day decoration may pass behind aheading, never across it. Reported to Shared so every site's heading is clear of the fall. */--.c4 h1{position:relative;z-index:10011}++.c4 h1{position:relative;z-index:10011}\\ No newline at end of file@@@ -28,12 +28,15 @@ const TASK_ROWS_MAX = 20000;const TABLES = [{table: 'run_tasks', subject: 'subject', file: 'run-tasks.json', order: 'created_at',-- columns: ['id', 'project_id', 'job_id', 'repo', 'ref', 'prompt', 'agent', 'push', 'pull_request', 'state', 'result', 'error', 'started_at', 'finished_at', 'created_at', 'updated_at'],++ columns: ['id', 'project_id', 'job_id', 'repo', 'ref', 'prompt', 'agent', 'push', 'pull_request', 'state', 'result', 'error', 'started_at', 'finished_at', 'created_at', 'updated_at', 'machine', 'pin'],// Erased by extraErase below, after the rows that point at these task ids (the SDK deletes the table's rows// before calling it, so a default delete here would lose the ids first).erase: { keep: 'erased with its task-keyed rows by the service extraErase' },},{ table: 'codes_user_settings', subject: 'subject', file: 'settings.json', order: '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'] },// The token itself is never stored; its jti is an internal handle and stays out of the export.{ table: 'codes_run_tokens', subject: 'subject', file: 'run-tokens.json', order: 'created_at', columns: ['id', 'task_id', 'attempt_id', 'provider', 'expires_at', 'created_at'] },{ table: 'codes_outbox', subject: 'subject', file: 'events-sent.json', order: 'created_at', columns: ['event_id', 'topic', 'created_at', 'published_at'] },@@@ -54,6 +57,8 @@ const TASK_KEYED = [// The approvals the person decided, and the ones still waiting when their account is erased.{ table: 'codes_approvals', file: 'approvals.json', columns: 'l.id, l.task_id, l.tool, l.summary, l.decision, l.message, l.created_at, l.decided_at', order: 'l.created_at ASC', kind: 'approvals', max: TASK_ROWS_MAX },{ table: 'codes_events_inbox', file: null, kind: 'events_received' },++ // The one notice sent per task and state (Codes C9c): no content of its own, erased with the task, not exported.++ { table: 'codes_notices', file: null, kind: 'notices' },];async function exists(q, table) {@@@ -51,6 +51,8 @@ const { createCodesReadiness } = require('./observability');const { createActorLimits } = require('./http/actor-limits');const { createForgejoClient } = require('./domain/forgejo');const { createMachines } = require('./domain/machines');++const { createFleet } = require('./domain/fleet');++const { createNotices } = require('./domain/notices');const { createUserSettings } = require('./domain/user-settings');const { createGitHub } = require('./domain/github');const { createUsage } = require('./domain/usage');@@@ -85,18 +87,22 @@ async function createApp(opts = {}) {const runTokens = createRunTokens({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });// The task's attempts (stage C2): every Run job is one attempt, and the engine decides what happens next.const attempts = createAttempts({ store, config, log, harnesses });++ // The fleet (C9c): each machine's agents and limit windows, reported with its heartbeat and read from Bot.++ const fleet = createFleet({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log, now: store.now, balance: opts.balance || null });++ // A person's choices (their notification switches) and the notices a task sends once (C9c).++ const userSettings = createUserSettings({ store });++ 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, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });-- const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks, connections, runTokens, attempts, benchPublisher };++ 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 };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 });-- ctx.userSettings = createUserSettings({ store });ctx.github = createGitHub({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });ctx.usage = createUsage({ store });// The conductor (Codes C9b) and its approvals inbox. The conductor starts Run jobs and reads their events through// tasks, so the two are wired to each other here, not in a constructor (which would be a cycle).-- ctx.notify = opts.notify || createNotify({ config, fetchImpl: fetchImpl || globalThis.fetch, log });ctx.approvals = createApprovals({ store, config, log, notify: ctx.notify, userSettings: ctx.userSettings });ctx.conductor = createConductor({store, config, log, run, attempts, tasks, sessions, approvals: ctx.approvals, notify: ctx.notify,@@@ -202,7 +208,7 @@ async function createApp(opts = {}) {// Run on your machine (plan T16): coding tasks on the person's own machine, through OpenVibe Run, from the /run// pages below or, for a first-party service acting for the person, from /api/v1/tasks (codes.task.*).app.use('/api/v1', createApi(ctx));-- app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, actorLimits: ctx.actorLimits, log }));++ app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, log }));app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));// A person's connections: their own provider keys (stored in OpenVibe.AI, never here) and their machines' plans.app.use('/api/v1', createConnectionsApi({ config, store, connections, actorLimits: ctx.actorLimits, log }));@@@ -210,7 +216,7 @@ async function createApp(opts = {}) {// The conductor's own routes (Codes C9b): its turns, sub-tasks, and the approvals inbox the MCP tools speak to.app.use('/api/v1', createConductorApi({config, store, keys, harnesses, tasks, conductor: ctx.conductor, approvals: ctx.approvals,-- machines: ctx.machines, actorLimits: ctx.actorLimits, log,++ machines: ctx.machines, fleet: ctx.fleet, actorLimits: ctx.actorLimits, log,}));app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));@@@ -111,6 +111,11 @@ function load(env = process.env) {retentionDays: Math.max(1, int(env.CODES_SESSION_RETENTION_DAYS, 90)),retentionIntervalMs: Math.max(60_000, int(env.CODES_SESSION_RETENTION_INTERVAL_MS, 86_400_000)),},++ // OpenVibe.Bot (Codes C9c): a person's machines, the fleet each one reports with its heartbeat, and the device++ // id Run places a job on. Codes reads it with the person's own token, on the loopback.++ bot: { internalUrl: trim(env.CODES_BOT_INTERNAL_URL || 'http://127.0.0.1:4630') },++ // How long a machine's fleet reading is kept before Bot is asked again (the fleet's own cache).++ fleet: { ttlMs: Math.max(0, int(env.CODES_FLEET_TTL_MS, 60_000)) },// OpenVibe.Media: the private objects a task's pictures live in. A proof screenshot can show anything on the// person's screen, so it is never public: the bytes go to Media as a private object of the person, and the// session shows a short-lived signed URL that only that person can use. Media takes no app keys (C-71): Codes@@@ -147,7 +152,7 @@ function load(env = process.env) {// Anthropic one: the conductor is the only Anthropic session).conductor: {wallMs: Math.max(60_000, int(env.CODES_CONDUCTOR_TURN_MS, 4 * 60 * 60_000)),-- subagentAgent: String(env.CODES_CONDUCTOR_SUBAGENT_AGENT || 'codex').trim() || 'codex',++ subagentAgent: String(env.CODES_CONDUCTOR_SUBAGENT_AGENT || 'auto').trim() || 'auto',},// The event stream of a task (GET /api/v1/tasks/:id/events): the heartbeat that keeps a connection alive.sse: { heartbeatMs: Math.max(1000, int(env.CODES_SSE_HEARTBEAT_MS, 15_000)) },@@@ -25,19 +25,21 @@const { chain, budget, router } = require('../../engine');/** Codes' harness ids and the engine's short runner ids for the same harness. */--const RUNNER_OF = { 'claude-code': 'claude', codex: 'cx', 'command-code': 'cc', opencode: 'oc', deepseek: 'dsh' };--const HARNESS_OF = { claude: 'claude-code', cx: 'codex', cc: 'command-code', oc: 'opencode', dsh: 'deepseek' };++const RUNNER_OF = { 'claude-code': 'claude', codex: 'cx', 'command-code': 'cc', opencode: 'oc', deepseek: 'dsh', 'minimax-code': 'mm' };++const HARNESS_OF = { claude: 'claude-code', cx: 'codex', cc: 'command-code', oc: 'opencode', dsh: 'deepseek', mm: 'minimax-code' };--/** The order a runner switch tries the harnesses in (the ladder's order, flattened). */--const FALLBACK_ORDER = ['cx', 'claude', 'cc', 'oc', 'dsh'];++/** The order a runner switch tries the harnesses in (the ladder's order, flattened). Never Claude: a sub-task is never++ * an Anthropic one (Codes C9b's rule), so the conductor's session stays the only Anthropic work. */++const FALLBACK_ORDER = ['cx', 'mm', 'cc', 'oc', 'dsh'];--/** Which harnesses may take which kind of task, cheapest first (the engine's ladder, Codes' harnesses). */++/** Which harnesses may take which kind of task, cheapest first (the engine's ladder, Codes' harnesses). Claude Code is++ * absent on purpose: it is the conductor's own session, never a sub-task's. */const TIER_ORDER = {-- basic: ['opencode', 'command-code', 'deepseek'],-- research: ['codex', 'opencode', 'deepseek'],-- standard: ['codex', 'claude-code', 'command-code', 'opencode', 'deepseek'],-- hard: ['codex', 'claude-code', 'deepseek'],-- design: ['codex', 'claude-code', 'opencode'],++ basic: ['opencode', 'command-code', 'minimax-code', 'deepseek'],++ research: ['codex', 'minimax-code', 'deepseek', 'opencode'],++ standard: ['minimax-code', 'codex', 'command-code', 'opencode', 'deepseek'],++ hard: ['codex', 'minimax-code', 'deepseek'],++ design: ['codex', 'minimax-code', 'opencode', 'deepseek'],};const TIER_EFFORT = { basic: 'low', research: 'medium', standard: 'high', hard: 'high', design: 'high' };@@@ -214,13 +216,14 @@ function createAttempts({ store, config = {}, log = console, harnesses = null }));}-- /** The engine's own vocabulary for Codes' harnesses: the ladder, the health and the catalogs it routes with. */-- function routing() {++ /** The engine's own vocabulary for Codes' harnesses: the ladder, the health, the catalogs and the fleet it routes++ * with. Claude Code is off here: a sub-task is never an Anthropic one (the conductor's session is the exception). */++ function routing(fleet = null) {const subagents = {}; const models = {}; const runners = [];for (const [harnessId, runner] of Object.entries(RUNNER_OF)) {const offer = catalog.get(harnessId);if (!offer) continue;-- subagents[runner] = { enabled: true, max: 4, model: null };++ subagents[runner] = { enabled: harnessId !== 'claude-code', max: 4, model: null };models[runner] = (offer.agents || []).map((a) => a.model).filter(Boolean);runners.push(runner);}@@@ -230,17 +233,42 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })for (const tier of router.TIERS) {ladder[tier] = (TIER_ORDER[tier] || []).map((id) => {const runner = RUNNER_OF[id];-- const offer = catalog.get(id);-- const agent = offer && offer.agents && offer.agents[0];-- return [runner, agent ? agent.model : null, TIER_EFFORT[tier]];-- }).filter(([runner, model]) => subagents[runner] && (!model || models[runner].includes(model)));++ // No model: each harness runs its own default (the offer's model is the harness's own label, not a++ // model id to pass on the command line). A caller that names a model still gets it through args.model.++ return [runner, null, TIER_EFFORT[tier]];++ }).filter(([runner]) => subagents[runner]);}return {-- cfg: { subagents, budget: config.budget || {}, fallback: { order: FALLBACK_ORDER.filter((r) => subagents[r]) } },-- health, counts: {}, models, runners, ladder,++ cfg: { subagents, budget: config.budget || {}, fallback: { order: FALLBACK_ORDER.filter((r) => subagents[r] && subagents[r].enabled) } },++ health, counts: {}, models, runners, ladder, fleet,};}++ /**++ * Why no agent could take the task, in one line. The router's silence is not something a person can act on, so++ * the task says what each agent is short of: a window that is spent, a balance that is gone, a sign-in that is++ * missing. Only what was read is said; an agent with room is not mentioned.++ *++ * @param {object} [fleet] the fleet per runner: { [runner]: { signed_in, windows, balance_usd } }++ * @returns {string} the reason, in plain words++ */++ function whyNoAgent(fleet) {++ const rows = Object.entries(fleet || {});++ if (!rows.length) return 'no agent on your machines could take it right now';++ const parts = [];++ for (const [runner, row] of rows) {++ const harness = HARNESS_OF[runner];++ const name = (harness && catalog.get(harness) && catalog.get(harness).name) || harness || runner;++ if (row.signed_in === false) { parts.push(`${name} is not signed in`); continue; }++ const usd = row.balance_usd == null || row.balance_usd === '' ? NaN : Number(row.balance_usd);++ if (Number.isFinite(usd) && usd < router.MIN_BALANCE_USD) { parts.push(`${name} has $${usd.toFixed(2)} left`); continue; }++ const spent = (row.windows || []).filter((w) => router.windowFull(w)).map((w) => w.name).filter(Boolean);++ if (spent.length) { parts.push(`${name}'s ${spent.join(' and ')} ${spent.length > 1 ? 'are' : 'is'} spent`); continue; }++ parts.push(`${name} is busy`);++ }++ return `no agent has room right now: ${parts.join('; ')}`;++ }++/*** The next agent for a task, from the engine's router (engine/router.js): the tier of the brief decides the* ladder, and the first harness of it that may take the task takes it. The agent that just stopped is skipped.@@@ -251,7 +279,9 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })if (!sub || !sub.enabled) return false;if ((ctx.counts[runner] || 0) >= sub.max) return false;const h = ctx.health[runner];-- return !!h && h.state === 'ok';++ if (!h || h.state !== 'ok') return false;++ // The fleet: no job goes to an agent whose window is full or that is not signed in on any machine.++ return router.roomFor(ctx.fleet, runner);};const picked = router.pick(prompt, ctx);const tier = picked ? picked.tier : router.classify(prompt).tier;@@@ -357,8 +387,8 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })* @param {object} [what.result] the job's result (what it changed and what it said)* @returns {object} the plan: an outcome ('done', 'unfinished', 'stopped') or a follow-up attempt*/-- function decide({ task, attempts, ending, result = null }) {-- const ctx = routing();++ function decide({ task, attempts, ending, result = null, fleet = null }) {++ const ctx = routing(fleet);const upto = (attempts || []).filter((a) => Number(a.n) <= Number(ending.n));const facts = factsOf(result);const engineAttempts = upto.map((a) => engineAttempt(a, upto, a.id === ending.id ? facts : null));@@@ -446,7 +476,7 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })return {STATES, REASONS, KINDS, REASON_TEXT, RUNNER_OF, HARNESS_OF,-- list, get, byJob, create, bind, end, spend, decide, present, routing, nextAgent, nameOf, wordsOf, budgetNote,++ list, get, byJob, create, bind, end, spend, decide, present, routing, nextAgent, whyNoAgent, nameOf, wordsOf, budgetNote,};}@@@ -121,14 +121,17 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas/*** Create the conductor: the task row, its one attempt and the hand-over turn. The first turn starts at once-- * (startNext), so a person who pressed "Start your conductor" sees it begin to work.++ * (startNext), so a person who pressed "Start your conductor" sees it begin to work. A chosen machine is also++ * `pin`, its OpenVibe.Bot device id (`dev_…`, Codes C9c): every turn is pinned to it on Run, so the session runs++ * where its folder and its memory are.*/-- async function create({ subject, project, folder, machine = null, model = 'opus', safety = 'ask', note = null, key = null } = {}) {++ async function create({ subject, project, folder, machine = null, pin = null, model = 'opus', safety = 'ask', note = null, key = null } = {}) {const work = validFolder(folder);if (!work) throw Object.assign(new Error('the working folder must be an absolute path'), { status: 422, code: 'conductor.folder_invalid' });if (!MODEL.test(String(model || ''))) throw Object.assign(new Error('the model is not a model name'), { status: 422, code: 'conductor.model_invalid' });if (!SAFETY.includes(safety)) throw Object.assign(new Error('safety is ask, project or full'), { status: 422, code: 'conductor.safety_invalid' });if (machine && !NODE.test(String(machine))) throw Object.assign(new Error('the machine is not one of the person\'s machines'), { status: 422, code: 'conductor.machine_invalid' });++ if (pin != null && !/^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(pin))) throw Object.assign(new Error('the pin is not a machine id'), { status: 422, code: 'conductor.machine_invalid' });if (!subject) throw Object.assign(new Error('a conductor belongs to a person'), { status: 422, code: 'conductor.subject_required' });const first = fragment() + (note && String(note).trim() ? `\n\n---\n\n## What the owner asked now\n\n${String(note).trim().slice(0, MAX_NOTE)}\n` : '');@@@ -138,9 +141,9 @@ function createConductor({ store, config = {}, log = console, run, attempts, taslet row;await store.tx(async () => {row = await db.one(-- `INSERT INTO run_tasks (id, subject, project_id, repo, prompt, agent, model, kind, folder, machine, safety, state, idempotency_key, created_at, updated_at)-- VALUES ($1, $2, $3, $4, $5, 'claude-code', $6, 'conductor', $7, $8, $9, 'queued', $10, $11, $11) RETURNING *`,-- [id, subject, project, repo, first.slice(0, 16000), String(model).slice(0, 100), work, machine || null, safety, key || null, at],++ `INSERT INTO run_tasks (id, subject, project_id, repo, prompt, agent, model, kind, folder, machine, pin, safety, state, idempotency_key, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, 'claude-code', $6, 'conductor', $7, $8, $9, $10, 'queued', $11, $12, $12) RETURNING *`,++ [id, subject, project, repo, first.slice(0, 16000), String(model).slice(0, 100), work, machine || null, pin || null, safety, key || null, at],);});await attempts.create({@@@ -232,6 +235,9 @@ function createConductor({ store, config = {}, log = console, run, attempts, tasjob = await run.submit({subject: task.subject, project: task.project_id, args: turnArgs(task, turn, attempt), key: turn.id,limits: { wall_ms: wallMs, cpu_ms: wallMs, mem_bytes: 4 * 1024 ** 3 }, ttlMs: Math.min(86_400_000, wallMs + 10 * 60_000),++ // Pinned to the machine the person chose (Codes C9c): the turn resumes a session whose folder and++ // memory are there, so Run must not place it anywhere else.++ node: task.pin || null,});} catch (e) {const why = String((e && (e.detail || e.message)) || 'the turn could not start').slice(0, 500);nnew file mode 100644@@@ -0,0 +1,267 @@++'use strict';++++/**++ * The fleet (Codes C9c): what each of a person's machines says about its coding agents right now — installed, signed++ * in, and every limit window with when it frees.++ *++ * The machine reports it with its heartbeat to OpenVibe.Bot (OpenVibe.Node `logins`), and Bot answers it for the++ * machine's owner at GET /api/v1/machines with the person's own token. This module reads that answer, keeps the latest++ * per machine in codes_machine_fleet, and answers the Agents page, the router and the MCP fleet tool from it. It stores++ * no key, no token and no account name: the Node reports labels, numbers and times only.++ *++ * createFleet({ config, store, fetchImpl, log, now })++ * refresh(subject, token) read Bot now and replace the stored rows → { ok, machines } or { ok: false, problem }++ * latest(subject) the stored rows per machine → { machines: [{ id, node_id, name, online, agents }], at }++ * byHarness(subject) per harness id, the best state across the person's machines (the router's input)++ * agents(subject, harnesses) one row per agent for the Agents page, usable first++ *++ * A Bot that is not configured or does not answer is a problem beside what is stored, never a silent empty fleet: the++ * page keeps the last reading and says when it was taken.++ */++++const { roomFor, windowFull, FULL_PCT, MIN_BALANCE_USD } = require('../../engine/router');++const { readBalances } = require('../../engine/balance');++++const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const AGENT = /^[a-z0-9-]{1,40}$/;++const MACHINE = /^[A-Za-z0-9_-]{1,128}$/;++const STATE = { ok: 0, low: 1, unknown: 2, absent: 3, login: 4, limited: 5, empty: 6 };++const LOW_PCT = 80;++++const iso = (value) => {++ if (value == null || value === '') return null;++ const numeric = Number(value);++ const ms = Number.isFinite(numeric) && numeric > 0 ? (numeric < 1e12 ? numeric * 1000 : numeric) : Date.parse(String(value));++ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;++};++const num = (value) => {++ const n = Number(value);++ return value == null || value === '' || !Number.isFinite(n) ? null : n;++};++const str = (value, max) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null);++++/** One reported window, with only the four things a person may see (a label, two numbers, a time). */++function windowOf(w) {++ if (!w || typeof w !== 'object' || Array.isArray(w)) return null;++ const used = num(w.used ?? w.used_percent ?? w.percent_used);++ const limit = num(w.limit ?? w.total ?? w.max);++ let pct = num(w.pct);++ if (pct == null && used != null && limit != null && limit > 0) pct = Math.round((used / limit) * 100);++ const name = str(w.name ?? w.label ?? w.id ?? w.window, 40);++ if (name == null && used == null && limit == null && pct == null) return null;++ return { name: name || 'window', used, limit, pct: pct == null ? null : Math.round(pct), resets_at: iso(w.resets_at ?? w.reset_at ?? w.resets) };++}++++/** The windows of one agent, merged across machines by name: the fullest reading wins (a stale machine never hides one). */++function mergeWindows(lists) {++ const by = new Map();++ for (const list of lists) {++ for (const w of list || []) {++ if (!w) continue;++ const seen = by.get(w.name);++ if (!seen || (w.pct ?? -1) > (seen.pct ?? -1)) by.set(w.name, w);++ }++ }++ return [...by.values()];++}++++/** The one-line state the page shows. An agent with room on one machine is usable, even when another machine's window is++ * full: the meter shows the fullest reading, and the row's machines say which one can take a job. A pay-per-token++ * agent whose balance is nearly gone is out of credit, whatever its windows say. */++function stateOf(row) {++ if (!row) return 'unknown';++ if (!row.installed) return 'absent';++ if (!row.signed_in) return 'login';++ const usd = row.balance_usd == null || row.balance_usd === '' ? NaN : Number(row.balance_usd);++ if (Number.isFinite(usd) && usd < MIN_BALANCE_USD) return 'empty';++ const windows = Array.isArray(row.windows) ? row.windows : [];++ if (row.room !== true && windows.length && windows.every((w) => w.pct != null && w.pct >= FULL_PCT)) return 'limited';++ if (windows.some((w) => w.pct != null && w.pct >= LOW_PCT)) return 'low';++ return 'ok';++}++++function createFleet({ config = {}, store, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), balance = null } = {}) {++ const db = store.db;++ const base = String((config.bot && config.bot.internalUrl) || '').replace(/\/+$/, '');++ // The balances of the pay-per-token accounts (Codes C9c): a plan window says how much of a plan is used, a++ // balance says what is left of the money. Read the way the portal reads it (engine/balance.js), cached for five++ // minutes: the answer moves in cents, and a task's routing may ask for it as often as it likes.++ const BALANCE_TTL_MS = 5 * 60_000;++ let balanceCache = { at: 0, value: null };++ const readBalancesNow = balance || (() => readBalances({ fetchImpl }));++ async function balances() {++ if (balanceCache.value && now() - balanceCache.at < BALANCE_TTL_MS) return balanceCache.value;++ let value = {};++ try { value = await readBalancesNow(); } catch (e) {++ log.warn(`[Codes] the balances could not be read: ${String((e && e.message) || e).slice(0, 160)}`);++ }++ balanceCache = { at: now(), value: value || {} };++ return balanceCache.value;++ }++++ /** One read of Bot's machines for this person. → { status, doc } */++ async function read(token) {++ if (!base) throw Object.assign(new Error('OpenVibe.Bot is not configured, so the fleet cannot be read'), { status: 503, code: 'fleet.unavailable' });++ let res;++ try {++ res = await fetchImpl(`${base}/api/v1/machines`, {++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },++ signal: AbortSignal.timeout(8000),++ });++ } catch (e) {++ throw Object.assign(new Error(`OpenVibe.Bot did not answer: ${e.message}`), { status: 503, code: 'fleet.unavailable' });++ }++ if (res.status === 401) throw Object.assign(new Error('the session expired'), { status: 401, code: 'fleet.expired' });++ const doc = await res.json().catch(() => null);++ if (!res.ok || !doc) {++ throw Object.assign(new Error((doc && (doc.detail || doc.title)) || `OpenVibe.Bot answered ${res.status}`), { status: res.status === 403 ? 403 : 503, code: 'fleet.unavailable' });++ }++ return doc;++ }++++ /**++ * Read Bot now and replace what Codes holds for each machine whose answer names a fleet. A machine that is gone from++ * the answer is gone here too; a machine that answers no fleet keeps its last reading (the Node may be older).++ */++ async function refresh(subject, token) {++ if (!USER.test(String(subject || '')) || !token) return { ok: false, problem: { status: 401, detail: 'sign in again to see your machines' } };++ let doc;++ try { doc = await read(token); } catch (e) {++ if (e.status === 401) throw e;++ log.warn(`[Codes] the fleet could not be read: ${String(e && e.message).slice(0, 200)}`);++ return { ok: false, problem: { status: e.status || 503, detail: String(e.message || 'the fleet could not be read').slice(0, 300) } };++ }++ const machines = (Array.isArray(doc.machines) ? doc.machines : []).filter((m) => m && MACHINE.test(String(m.id || '')));++ const at = new Date(now()).toISOString();++ await store.tx(async () => {++ const ids = machines.map((m) => String(m.id));++ await db.exec('DELETE FROM codes_machine_fleet WHERE subject = $1 AND NOT (machine = ANY($2))', [subject, ids]);++ for (const m of machines) {++ if (!Array.isArray(m.fleet)) continue; // no report from this machine yet: keep what Codes has++ const machine = String(m.id);++ await db.exec('DELETE FROM codes_machine_fleet WHERE subject = $1 AND machine = $2', [subject, machine]);++ for (const entry of m.fleet) {++ if (!entry || !AGENT.test(String(entry.agent || ''))) continue;++ const windows = (Array.isArray(entry.windows) ? entry.windows : []).map(windowOf).filter(Boolean).slice(0, 10);++ await db.exec(++ `INSERT INTO codes_machine_fleet (subject, machine, node_id, name, agent, installed, signed_in, windows, online, reported_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10::timestamptz, now())++ ON CONFLICT (subject, machine, agent) DO UPDATE SET node_id = EXCLUDED.node_id, name = EXCLUDED.name,++ installed = EXCLUDED.installed, signed_in = EXCLUDED.signed_in, windows = EXCLUDED.windows,++ online = EXCLUDED.online, reported_at = EXCLUDED.reported_at, updated_at = now()`,++ [subject, machine, str(m.node_id, 128), str(m.name, 120), String(entry.agent), !!entry.installed, !!entry.signed_in,++ JSON.stringify(windows), m.online !== false, iso(m.fleet_at) || at],++ );++ }++ }++ });++ return { ok: true, machines: await latest(subject).then((x) => x.machines), problem: null };++ }++++ /** The stored fleet, per machine, newest reading first. */++ async function latest(subject) {++ if (!USER.test(String(subject || ''))) return { ok: false, machines: [], at: null };++ const rows = await db.many(++ 'SELECT * FROM codes_machine_fleet WHERE subject = $1 ORDER BY name ASC NULLS LAST, machine ASC, agent ASC',++ [subject],++ );++ const by = new Map();++ for (const row of rows) {++ const key = row.machine;++ const machine = by.get(key) || { id: key, node_id: row.node_id || null, name: row.name || null, online: !!row.online, reported_at: null, agents: [] };++ if (row.reported_at && (!machine.reported_at || String(row.reported_at) > machine.reported_at)) machine.reported_at = iso(row.reported_at);++ machine.agents.push({++ agent: row.agent, installed: !!row.installed, signed_in: !!row.signed_in,++ windows: Array.isArray(row.windows) ? row.windows : [],++ });++ by.set(key, machine);++ }++ const machines = [...by.values()];++ const times = machines.map((m) => m.reported_at).filter(Boolean).sort();++ return { ok: true, machines, at: times.length ? times[times.length - 1] : null };++ }++++ /**++ * Per harness id, the best state across the person's machines — what the router reads. An agent is usable when at++ * least one machine has it installed and signed in with room; its windows are the fullest reading across machines.++ * A pay-per-token agent also carries its balance, when one was read: an agent whose money is nearly gone may not++ * take a job (the router's rule), so the reading belongs with the rest of its state.++ */++ async function byHarness(subject) {++ const { machines } = await latest(subject);++ const money = await balances();++ const out = {};++ for (const machine of machines) {++ for (const a of machine.agents) {++ const cur = out[a.agent] || (out[a.agent] = {++ installed: false, signed_in: false, windows: [], machines: [], _lists: [], _room: false, _best: null,++ });++ cur.installed = cur.installed || a.installed;++ cur.signed_in = cur.signed_in || a.signed_in;++ cur._lists.push(a.windows);++ const room = a.installed && a.signed_in && !a.windows.some((w) => windowFull(w, now()));++ cur._room = cur._room || room;++ cur.machines.push({ machine: machine.id, node_id: machine.node_id, name: machine.name, online: machine.online, room });++ if (room && !cur._best) cur._best = machine.id;++ }++ }++ for (const [agent, row] of Object.entries(out)) {++ row.windows = mergeWindows(row._lists);++ row.machine = row._best || (row.machines[0] && row.machines[0].machine) || null;++ const account = money[agent];++ row.balance_usd = account ? account.usd : null;++ row.balance_at = account ? account.at : null;++ row.room = row._room || roomFor({ [agent]: row }, agent, now());++ delete row._lists; delete row._best;++ }++ return out;++ }++++ /** The fleet per runner id (cx, oc, cc, mm), what engine/router.js reads. */++ async function forRouter(subject, harnessOf) {++ const by = await byHarness(subject);++ const out = {};++ for (const [harness, row] of Object.entries(by)) {++ const runner = harnessOf ? harnessOf(harness) : harness;++ if (!runner) continue;++ 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,++ };++ }++ return out;++ }++++ /** One row per agent, for the Agents page: usable and with the most room first, then sign-in needed, then limited. */++ async function agents(subject, harnesses = null) {++ const by = await byHarness(subject);++ const rows = Object.entries(by).map(([agent, row]) => {++ const offer = harnesses && harnesses.get ? harnesses.get(agent) : null;++ const state = stateOf(row);++ const used = row.windows.reduce((max, w) => Math.max(max, w.pct == null ? 0 : w.pct), 0);++ return {++ agent, name: (offer && offer.name) || agent, state, installed: row.installed, signed_in: row.signed_in,++ windows: row.windows, machines: row.machines, machine: row.machine, used,++ balance_usd: row.balance_usd ?? null, balance_at: row.balance_at ?? null,++ };++ });++ return rows.sort((a, b) => (STATE[a.state] ?? 9) - (STATE[b.state] ?? 9) || a.used - b.used || a.name.localeCompare(b.name));++ }++++ /**++ * The OpenVibe.Bot device id (`dev_…`) of one of the person's machines, given the id Codes knows it by (`nod_…`, or++ * the device id itself): what a Run job is pinned to (Codes C9c; Run's `job.node`). null when Bot's list has no such++ * machine; a Bot that does not answer throws (with a status), so a caller never pins to a guess.++ */++ async function deviceFor(token, nodeId) {++ if (!token) throw Object.assign(new Error('sign in again to read your machines'), { status: 401, code: 'fleet.expired' });++ const doc = await read(token);++ const hit = (Array.isArray(doc.machines) ? doc.machines : [])++ .find((m) => m && (m.id === nodeId || m.node_id === nodeId) && MACHINE.test(String(m.id || '')));++ return hit ? { device: String(hit.id), node_id: str(hit.node_id, 128), name: str(hit.name, 120), online: hit.online !== false } : null;++ }++++ return { refresh, latest, byHarness, forRouter, agents, deviceFor, balances, stateOf, windowOf, mergeWindows, enabled: !!base };++}++++module.exports = { createFleet, stateOf, windowOf, mergeWindows, FULL_PCT, LOW_PCT, MIN_BALANCE_USD, STATE };@@@ -18,6 +18,8 @@* anywhere but api.github.com.*/++const { createShip } = require('./ship');++const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,38})$/;const NAME = /^[A-Za-z0-9._-]{1,100}$/;const PR_URL = /^https:\/\/github\.com\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/pull\/(\d{1,10})\/?$/;@@@ -58,10 +60,12 @@ function ciWord(status) {return ({ success: 'Passed', failure: 'Failed', error: 'Errored', pending: 'Running' })[state] || null;}--function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch, log = console } = {}) {++function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch, log = console, ship = null } = {}) {const token = String((config.github && config.github.token) || '').trim();const cache = new Map();const searches = new Map();++ // The host's own deploy signal: the service's /release.json, which `ovhost status` reads too (Codes C9c).++ const deployments = ship || createShip({ config, fetchImpl, log });const headers = () => ({Accept: 'application/vnd.github+json',@@@ -171,10 +175,14 @@ function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch,url, number: parts.number, owner: parts.owner, name: parts.name,state: doc.merged ? 'merged' : (doc.state === 'closed' ? 'closed' : 'open'),merge: mergeWord(doc),++ merged: !!doc.merged,++ merge_commit_sha: doc.merge_commit_sha ? String(doc.merge_commit_sha).slice(0, 40) : null,ci,title: doc.title ? String(doc.title).slice(0, 200) : null,branch: doc.head && doc.head.ref ? String(doc.head.ref).slice(0, 120) : null,base: doc.base && doc.base.ref ? String(doc.base.ref).slice(0, 120) : null,++ // CI, merge, then the deploy the host reports for the service this repository builds.++ deploy: await deployments.deployOf({ repo: `${parts.owner}/${parts.name}`, mergeCommitSha: doc.merge_commit_sha || null, merged: !!doc.merged }),checked_at: new Date().toISOString(),};}nnew file mode 100644@@@ -0,0 +1,73 @@++'use strict';++++/**++ * Telling a person a task finished, or needs them (Codes C9c). One OpenVibe notification through the Network — the only++ * notification store (ADR-020) — honouring the two switches in Settings (notify_done, notify_needs).++ *++ * The notice is recorded once per task and state in codes_notices in the same act that sends it, so a redelivered++ * Run event, a fallback read or two racing ticks never tell the person twice. The Action Center needs no push: it reads++ * the same tasks (codes.task@1 → common.action-item@1), so a task that ended is there whether or not a notification++ * could be sent.++ *++ * createNotices({ store, config, notify, userSettings, log })++ * done(task, note) → { sent, reason?, skipped? }++ * needs(task, note) → { sent, reason?, skipped? }++ *++ * A notification that cannot be sent never stops the thing it is about, and a switched-off choice is not an error.++ */++++const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;++const KINDS = new Set(['done', 'needs']);++++const text = (value, max) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim().slice(0, max);++++function createNotices({ store, config = {}, notify = null, userSettings = null, log = console } = {}) {++ const db = store.db;++++ /**++ * One notice for one task and state. The row is claimed first: if another caller already claimed it, nothing is++ * sent. The choice and the push are best effort and are said in the answer, never thrown.++ */++ async function send(task, kind, note) {++ if (!task || !TASK.test(String(task.id || '')) || !KINDS.has(kind)) return { sent: false, reason: 'bad_input' };++ if (!task.subject) return { sent: false, reason: 'no_subject' };++ const claimed = await db.maybe(++ `INSERT INTO codes_notices (task_id, kind, result) VALUES ($1, $2, $3)++ ON CONFLICT (task_id, kind) DO NOTHING RETURNING task_id`,++ [task.id, kind, text(note, 300) || null],++ );++ if (!claimed) return { sent: false, reason: 'already' };++ if (!notify) return { sent: false, reason: 'not_configured' };++ let settings = { notify_done: true, notify_needs: true };++ try { if (userSettings) settings = await userSettings.get(task.subject); } catch { /* the default is to tell */ }++ if (kind === 'done' && settings.notify_done === false) return { sent: false, reason: 'switched_off' };++ if (kind === 'needs' && settings.notify_needs === false) return { sent: false, reason: 'switched_off' };++ const base = String(config.baseUrl || '').replace(/\/+$/, '');++ const line = text(note, 300);++ try {++ const out = await notify.push({++ subject: task.subject,++ type: 'SYSTEM',++ title: kind === 'done' ? 'Your task is done' : 'Your task needs you',++ message: line || (kind === 'done' ? 'It finished. Open it to see what changed.' : 'It stopped and needs a person to carry on.'),++ url: `${base}/tasks/${encodeURIComponent(task.id)}`,++ priority: kind === 'needs' ? 'high' : 'normal',++ category: 'codes',++ icon: kind === 'done' ? '✅' : '🙋',++ });++ return out || { sent: true };++ } catch (e) {++ log.warn(`[Codes] notice ${kind} for ${task.id} was not sent: ${String((e && e.message) || e).slice(0, 200)}`);++ return { sent: false, reason: 'network_error' };++ }++ }++++ /** "Done": the task's outcome is done. */++ const done = (task, note = null) => send(task, 'done', note);++ /** "Needs you": an unfinished task, a stop at its budget, or a task that could not start. */++ const needs = (task, note = null) => send(task, 'needs', note);++++ return { send, done, needs, KINDS };++}++++module.exports = { createNotices, KINDS };@@@ -50,9 +50,11 @@ function createRunClient({ config, fetchImpl = globalThis.fetch, now = () => Dat}/*** A coding job for `subject` in `project`, only on that person's own machine. A conductor's turn asks for more: its-- * wall time (a session that works through a plan), its TTL and its own memory, all bounded here.++ * wall time (a session that works through a plan), its TTL and its own memory, all bounded here; and when its++ * machine is known (`node`, the OpenVibe.Bot device id `dev_…`), the job is pinned to it (contracts 0.250.0): Run++ * runs it nowhere else, and refuses it `409 run.job.node_offline` when the machine is away.*/-- async function submit({ subject, project, args, key, limits = null, ttlMs = null }) {++ async function submit({ subject, project, args, key, limits = null, ttlMs = null, node = null }) {const wall = Math.max(1000, Math.min(90 * 60_000, Number(limits && limits.wall_ms) || Number(args && args.limits && args.limits.ms) || 30 * 60_000));const body = {class: 'code', artifact: { name: 'openvibe-codes', version: JOB_PROTOCOL }, args,@@@ -64,6 +66,9 @@ function createRunClient({ config, fetchImpl = globalThis.fetch, now = () => Datttl_ms: Math.max(60_000, Math.min(86_400_000, Number(ttlMs) || 45 * 60_000)),egress: 'public',requirements: { kind: 'run.code', mobility: 'job', latency_class: 'background', objective: 'balanced', trust: ['user-owned'] },++ // The machine the job runs on, pinned: a conductor's folder and memory are there (Codes C9c). Absent, Run++ // places the job where it fits, as before.++ ...(node ? { node: String(node).slice(0, 64) } : {}),};const out = await call('POST', '/api/v1/jobs', { subject, project, body, key });return out.job;@@@ -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 }) {++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 }) {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).@@@ -169,7 +169,23 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou// The answer is cached for a minute, so a task that ends does not ask again for every attempt.const connectionCache = new Map();const CONNECTION_TTL_MS = 60_000;++ // The fleet the person's machines reported (Codes C9c), cached for a minute like the connections: one task's++ // routing and its follow-ups read the same reading, and a page view does not ask Bot for every task.++ const fleetCache = new Map();++ const FLEET_TTL_MS = Number.isFinite(Number(config.fleet && config.fleet.ttlMs)) ? Math.max(0, Number(config.fleet.ttlMs)) : 60_000;++ let providerMap = null;let harnessInfoCache = null;++ async function fleetFor(subject) {++ if (!fleet || !subject) return null;++ const hit = fleetCache.get(subject);++ if (hit && hit.until > store.now()) return hit.value;++ let value = null;++ try { value = await fleet.forRouter(subject, (agent) => attemptStore.RUNNER_OF[agent] || null); } catch (e) {++ log.warn(`[Codes] the fleet of ${subject} could not be read: ${String((e && e.message) || e).slice(0, 200)}`);++ }++ fleetCache.set(subject, { value, until: store.now() + FLEET_TTL_MS });++ return value;++ }async function personConnections(subject) {if (!connections || !subject) return null;const hit = connectionCache.get(subject);@@@ -460,42 +476,58 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouasync function start({ subject, project, args, via = null, key = null, actorRef = null }) {const id = store.newId('tsk');const at = store.now();++ // A task with no agent named is `auto` (Codes C9c): the router reads the fleet — who is signed in, whose window++ // still has room — and the task is started on the agent it picks. Never a fixed agent, and never an Anthropic one.++ let agent = args.agent || null;++ let model = args.model || null;++ let why = agent ? `the first try, with ${attemptStore.nameOf(agent)}` : 'the first try, with the agent that fits the task';++ // Nothing may take it: the task says why, and needs a person. It never starts on an agent without room.++ let refusal = null;++ if (!agent) {++ const f = await fleetFor(subject);++ const picked = attemptStore.nextAgent(args.prompt, { ...attemptStore.routing(f), fleet: f }, null);++ if (picked) { agent = picked.agent; model = picked.model || null; why = picked.why || why; }++ else refusal = attemptStore.whyNoAgent(f);++ }++ const runArgs = { ...args, agent, model };const actorRefValue = actorRef && typeof actorRef === 'object' && !Array.isArray(actorRef) ? JSON.stringify(actorRef) : 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, args.repo || 'bench://', args.ref, args.prompt, args.agent, !!args.push, !!(args.push && args.push.pull_request), via, key, actorRefValue, at]);++ [id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, at]);await taskOutbox.emitTask(inserted, null);return inserted;});// The task's first attempt: every Run job is one attempt, and this one is the task as it was asked.// The budget of the attempt: the task's limits (a benchmark's per-task limits) or the person's default.-- const budget = budgetFor(args.limits);++ const budget = budgetFor(runArgs.limits);const first = await attemptStore.create({-- task: inserted, agent: args.agent || null, model: args.model || null,-- why: args.agent ? `the first try, with ${attemptStore.nameOf(args.agent)}` : 'the first try, with the agent that fits the task',++ task: inserted, agent: agent || null, model: model || null, why,kind: 'first', state: 'queued', budget,});let job;try {++ if (refusal) throw Object.assign(new Error(refusal), { code: 'codes.no_agent' });// The job's harness runs with a short-lived run token that names the person (Codes C3): the environment// travels with the job's input and the machine's worker hands it to the harness.-- const funding = await attemptEnv({ subject, taskId: id, attemptId: first.attempt.id, agent: args.agent || null, budget });++ const funding = await attemptEnv({ subject, taskId: id, attemptId: first.attempt.id, agent: agent || null, budget });await attemptStore.bind(first.attempt.id, { run_mode: funding.mode });-- job = await run.submit({ subject, project, args: funding.env ? { ...args, env: funding.env } : args, key: id });++ job = await run.submit({ subject, project, args: funding.env ? { ...runArgs, env: funding.env } : runArgs, key: id });} catch (e) {const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;-- await attemptStore.end(first.attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });-- return await store.tx(async () => {-- 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++ await attemptStore.end(first.attempt.id, { state: 'failed', reason: refusal ? 'refused' : 'error', finished_at: store.iso() });++ const failed = await store.tx(async () => {++ const row = await db.one(`UPDATE run_tasks SET state = 'failed', error = $2, outcome = 'unfinished', outcome_note = $3, finished_at = $4::timestamptz, updated_at = $5WHERE id = $1 RETURNING *`, [id, why, `the task could not start: ${why}`, store.iso(), store.now()]);-- await taskOutbox.emitTask(failed, 'queued');++ await taskOutbox.emitTask(row, 'queued');if (benchAttemptEnded && actorRef && asObject(actorRef).bench) {-- await benchAttemptEnded(failed).catch((publishError) => log.warn(`[Codes] benchmark publish failed for task ${failed.id}: ${String(publishError && publishError.message).slice(0, 200)}`));++ await benchAttemptEnded(row).catch((publishError) => log.warn(`[Codes] benchmark publish failed for task ${row.id}: ${String(publishError && publishError.message).slice(0, 200)}`));}-- return failed;++ return row;});++ if (notices) await notices.needs(failed, `the task could not start: ${why}`);++ return failed;}await attemptStore.bind(first.attempt.id, { job_id: job.id, state: 'queued' });return await store.tx(async () => {@@@ -558,6 +590,21 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou});}++ /**++ * Tell the person once about a task's outcome (Codes C9c): done, or needs them. The notice module claims the task++ * and state before it sends, so a redelivered event or a second caller changes nothing. A stopped task is the++ * person's own act and tells them nothing.++ */++ async function notifyOutcome(row) {++ if (!notices || !row) return;++ if (row.outcome === 'done') {++ const summary = String((asObject(row.result) || {}).summary || '').trim();++ await notices.done(row, summary || row.outcome_note || 'the task finished');++ } else if (row.outcome === 'unfinished') {++ await notices.needs(row, row.outcome_note || row.error || 'the task needs a person to carry on');++ }++ }++/*** An attempt ended: the engine decides what happens next (server/domain/attempts.js) and this carries the* decision out — a follow-up attempt, or the task's outcome in plain words.@@@ -581,20 +628,24 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouconst note = ended && ended.reason === 'budget' && ['usd', 'tokens'].includes(ended.budget_hit)? attemptStore.budgetNote(ended): (done ? 'benchmark attempt finished' : (row.error || 'benchmark attempt failed'));-- return 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()]);++ await notifyOutcome(saved);++ return saved;}const attempts = await attemptStore.list(row.id);const ending = attempts[attempts.length - 1];if (!ending || !ENDED.has(ending.state)) return row;// The proof this attempt's machine collected: the items are kept, and each one is an event on the stream.const collected = await recordProof(row, ending);-- const plan = await attemptStore.decide({ task: row, attempts, ending, result: row.result });++ const plan = await attemptStore.decide({ task: row, attempts, ending, result: row.result, fleet: await fleetFor(row.subject) });if (plan.outcome) {// The outcome in plain words: what the engine decided, and what the proof says about it.const outcome = taskProof.outcomeOf(collected, { state: plan.outcome, note: plan.note });-- return 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()]);++ await notifyOutcome(saved);++ return saved;}if (!plan.kind) return row;const done = await startFollowUp(row, plan, ending);@@@ -649,6 +700,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouawait attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });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);return { attempt, created: true, row: failed, error: e.message };}let job;@@@ -663,6 +715,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouawait attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });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);return { attempt, created: true, row: failed, error: why };}await attemptStore.bind(attempt.id, { job_id: job.id, state: 'queued' });@@@ -739,6 +792,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouawait attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });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);return { error: why, row: failed, attempt };}await attemptStore.bind(attempt.id, { job_id: job.id, state: 'queued' });nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++++/**++ * Ship tracking (Codes C9c): after CI and the merge, is the service that repository builds actually deployed?++ *++ * The signal is the host's own: `ovhost status <service>` compares the service's `GET /release.json` (the deployed++ * commit, set at boot) with the commit it expected, and Codes reads that same `/release.json` from the service's public++ * origin. A PR's merge commit counts as deployed when the service reports it, or a later main commit that contains it++ * (GitHub's compare says ahead/identical). Nothing is guessed from a timer: when neither answer is available the state++ * is "not read" with the release the service does report.++ *++ * createShip({ config, fetchImpl, manifests, log })++ * deployOf({ repo, mergeCommitSha, merged }) → { state, service, origin, release, booted_at, checked_at, detail? }++ *++ * state: 'deployed' | 'pending' (merged, not live yet) | 'waiting' (not merged) | 'unknown' (nothing to read).++ */++++const REPO = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/;++const SHA = /^[0-9a-f]{7,40}$/i;++++/** The service a repository builds, from the contracts' manifests (a manifest without an origin is not deployable). */++function serviceFor(manifests, repo) {++ const m = REPO.exec(String(repo || ''));++ if (!m) return null;++ const want = `${m[1]}/${m[2]}`.toLowerCase();++ return (manifests || []).find((s) => s && typeof s.repository === 'string'++ && s.repository.toLowerCase() === want && typeof s.publicOrigin === 'string' && /^https:\/\//.test(s.publicOrigin)) || null;++}++++function createShip({ config = {}, fetchImpl = globalThis.fetch, manifests = null, log = console } = {}) {++ const list = manifests || require('openvibe-contracts').services.manifests;++ const token = String((config.github && config.github.token) || '').trim();++ const base = String(process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\/+$/, '');++++ async function releaseOf(origin) {++ const res = await fetchImpl(`${String(origin).replace(/\/+$/, '')}/release.json`, {++ headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(6000),++ });++ const doc = await res.json().catch(() => null);++ return res.ok && doc && typeof doc === 'object' ? doc : null;++ }++++ /** Does the deployed release contain the PR's merge commit? GitHub's compare answers when the shas differ. */++ async function contains(repo, sha, release) {++ if (!SHA.test(String(release || ''))) return null;++ try {++ const res = await fetchImpl(`${base}/repos/${repo}/compare/${sha}...${release}`, {++ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'openvibe-codes', ...(token ? { Authorization: `Bearer ${token}` } : {}) },++ signal: AbortSignal.timeout(6000),++ });++ if (!res.ok) return null;++ const doc = await res.json().catch(() => null);++ return doc && typeof doc.status === 'string' ? ['ahead', 'identical'].includes(doc.status) : null;++ } catch { return null; }++ }++++ /**++ * A pull request's deploy state.++ *++ * @param {object} what++ * @param {string} what.repo owner/name++ * @param {string|null} [what.mergeCommitSha] the merge commit GitHub reports++ * @param {boolean} [what.merged] whether the PR is merged++ * @returns {Promise<object>} { state, service, origin, release, booted_at, checked_at, detail? }++ */++ async function deployOf({ repo, mergeCommitSha = null, merged = false } = {}) {++ const checked_at = new Date().toISOString();++ const service = serviceFor(list, repo);++ if (!service) return { state: 'unknown', service: null, origin: null, release: null, booted_at: null, checked_at, detail: 'Codes knows no service for this repository' };++ let doc = null;++ try { doc = await releaseOf(service.publicOrigin); } catch (e) {++ log.warn(`[Codes] ${service.id}: /release.json could not be read: ${String(e && e.message).slice(0, 160)}`);++ }++ const release = doc && typeof doc.release === 'string' ? doc.release : null;++ const booted = doc && typeof doc.booted_at === 'string' ? doc.booted_at : null;++ const out = { service: service.id, origin: service.publicOrigin, release, booted_at: booted, checked_at };++ if (!merged || !mergeCommitSha) return { ...out, state: 'waiting', detail: 'the pull request is not merged yet' };++ if (!release) return { ...out, state: 'unknown', detail: 'the service did not answer /release.json, so Codes cannot tell whether it is live' };++ if (release === mergeCommitSha || release.startsWith(mergeCommitSha) || mergeCommitSha.startsWith(release)) {++ return { ...out, state: 'deployed', detail: null };++ }++ const ahead = await contains(repo, mergeCommitSha, release);++ if (ahead === true) return { ...out, state: 'deployed', detail: null };++ if (ahead === false) return { ...out, state: 'pending', detail: 'merged, not deployed yet' };++ return { ...out, state: 'unknown', detail: `the service reports release ${release}, which Codes could not compare with the merge` };++ }++++ return { deployOf, releaseOf, contains, enabled: true };++}++++module.exports = { createShip, serviceFor, REPO };@@@ -33,6 +33,19 @@ const age = (value) => {const mins = Math.max(0, Math.floor((Date.now() - ms) / 60000));return mins < 60 ? `${mins}m ago` : mins < 1440 ? `${Math.floor(mins / 60)}h ago` : `${Math.floor(mins / 1440)}d ago`;};++/** When a limit window frees, in the one phrase the page uses: "frees in 2 d · Tue 14:00 UTC". */++const whenUtc = (value) => {++ const ms = Date.parse(String(value || ''));++ if (!Number.isFinite(ms)) return null;++ const text = new Date(ms).toLocaleString('en-GB', { weekday: 'short', hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'UTC' }).replace(',', '');++ const mins = Math.round((ms - Date.now()) / 60000);++ const soon = mins <= 0 ? 'now' : mins < 60 ? `in ${mins} min` : mins < 2880 ? `in ${Math.round(mins / 60)} h` : `in ${Math.round(mins / 1440)} d`;++ return text ? `frees ${soon} · ${text} UTC` : null;++};++const FLEET_STATE = {++ ok: ['Has room', 'ok'], low: ['Nearly full', 'skip'], login: ['Sign in', 'bad'], limited: ['Full', 'bad'],++ empty: ['Out of credit', 'bad'], absent: ['Not installed', 'skip'], unknown: ['Not reported', 'skip'],++};const repoDetails = (repo) => {try {const url = new URL(String(repo || ''));@@@ -54,7 +67,7 @@ const PROVIDER_NAMES = { anthropic: 'Anthropic', openai: 'OpenAI', deepseek: 'Defunction createC4Routes(ctx) {const r = asyncRouter();-- const { config, store, harnesses, tasks, sso, connections, machines, userSettings, github, usage, conductor, approvals } = ctx;++ const { config, store, harnesses, tasks, sso, connections, machines, userSettings, github, usage, conductor, approvals, fleet } = ctx;const db = store.db;const form = express.urlencoded({ extended: false, limit: '16kb' });const page = (req, res, title, body, extra = {}) => {@@@ -222,10 +235,17 @@ ${patch ? `<details class="c4-fold c4-fold-wide"><summary><span class="c4-row-la}const pr = await github.pullRequest(href);const stateWord = pr.problem ? 'Not read' : (pr.state === 'merged' ? 'Merged' : pr.state === 'closed' ? 'Closed' : 'Open');++ // The deploy: the service's own /release.json, the same signal `ovhost status` reads (Codes C9c).++ const deploy = asObject(pr.deploy) || {};++ const deployWord = ({ deployed: 'Deployed', pending: 'Not live yet', waiting: 'Waiting for the merge', unknown: 'Not read' })[deploy.state] || 'Not read';++ const deployKind = deploy.state === 'deployed' ? 'ok' : (deploy.state === 'pending' ? 'open' : 'skip');++ const deployNote = deploy.state === 'deployed'++ ? `release ${esc(String(deploy.release || '').slice(0, 12))}${deploy.booted_at ? `, booted ${esc(age(deploy.booted_at))}` : ''}`++ : (deploy.detail ? esc(deploy.detail) : (deploy.service ? esc(deploy.service) : ''));const stateKind = pr.problem ? 'skip' : (pr.state === 'merged' ? 'ok' : pr.state === 'closed' ? 'bad' : 'open');return `<div class="c4-rows"><div class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${stateKind}" aria-hidden="true"></span><span class="c4-row-label">Pull request #${esc(pr.number || '')}</span><span class="c4-row-note">${esc(pr.title || `${pr.owner || ''}/${pr.name || ''}`)}</span>${pill(stateWord, stateKind)}</div><div class="c4-pr-actions"><a class="c4-opill c4-opill-primary" href="${esc(href)}" rel="noopener noreferrer">${icon('<path d="M14 3h7v7M21 3l-9 9"/><path d="M19 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5"/>')}<span>Open on GitHub</span></a>${pr.branch ? `<span class="c4-chip">${esc(pr.branch)} → ${esc(pr.base || 'base')}</span>` : ''}</div>--${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="c4-facts"><div><dt>CI</dt><dd>${pill(pr.ci || 'No checks reported', pr.ci === 'Passed' ? 'ok' : pr.ci === 'Failed' || pr.ci === 'Errored' ? 'bad' : 'skip')}</dd></div><div><dt>Merge</dt><dd>${pill(pr.merge || 'Open', pr.state === 'merged' ? 'ok' : pr.merge === 'Has conflicts' ? 'bad' : 'skip')}</dd></div><div><dt>Checked</dt><dd>${esc(age(pr.checked_at))}</dd></div></dl>`}</div>`;++${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="c4-facts"><div><dt>CI</dt><dd>${pill(pr.ci || 'No checks reported', pr.ci === 'Passed' ? 'ok' : pr.ci === 'Failed' || pr.ci === 'Errored' ? 'bad' : 'skip')}</dd></div><div><dt>Merge</dt><dd>${pill(pr.merge || 'Open', pr.state === 'merged' ? 'ok' : pr.merge === 'Has conflicts' ? 'bad' : 'skip')}</dd></div><div><dt>Deploy</dt><dd>${pill(deployWord, deployKind)}${deployNote ? ` <span class="c4-hint">${deployNote}</span>` : ''}</dd></div><div><dt>Checked</dt><dd>${esc(age(pr.checked_at))}</dd></div></dl>`}</div>`;};// ── The conductor (C9b) ─────────────────────────────────────────────────────────────────────────────────────@@@ -488,6 +508,48 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductorsreturn res.redirect(303, `/approvals?${out ? 'ok' : 'error'}=${encodeURIComponent(out ? (decision === 'allow' ? 'Allowed' : 'Denied') : 'that approval is gone')}`);});++ // ── Agents: the fleet each machine reports, then the person's connections (keys live in OpenVibe.AI) ────────++ /**++ * One calm row per agent: its state, a meter for each limit window and when it frees. The fleet comes from the++ * machines' heartbeats through Bot (`fleet.refresh` writes it, `fleet.agents` reads it), so the page keeps the last++ * reading when Bot or a machine does not answer.++ */++ const fleetCard = async (req, res = null) => {++ let problem = null;++ let refreshed = null;++ if (fleet) {++ try {++ refreshed = await sso.asViewer(req, res, (token) => fleet.refresh(req.viewer.subject, token));++ if (refreshed && refreshed.problem) problem = refreshed.problem;++ } catch (e) {++ problem = { detail: String((e && e.message) || 'the fleet could not be read').slice(0, 240) };++ }++ }++ const rows = fleet ? await fleet.agents(req.viewer.subject, harnesses).catch(() => []) : [];++ const latest = fleet ? await fleet.latest(req.viewer.subject).catch(() => ({ at: null })) : { at: null };++ const meter = (w) => {++ const pct = Math.max(0, Math.min(100, Number(w.pct) || 0));++ const full = Number(w.pct) >= 95;++ const free = whenUtc(w.resets_at);++ const used = w.used != null && w.limit != null ? `${w.used} of ${w.limit}` : (w.pct != null ? `${Math.round(Number(w.pct))}%` : '');++ return `<div class="c4-meter"><span class="c4-meter-name">${esc(w.name || 'window')}</span><span class="c4-meter-track" role="img" aria-label="${esc(`${w.name || 'window'}: ${Math.round(pct)}% used${full ? ', full' : ''}`)}"><span class="c4-meter-fill${full ? ' is-full' : ''}" style="width:${pct}%"></span></span><span class="c4-meter-note">${used ? `<b>${esc(used)}</b>` : ''}${used && free ? ' · ' : ''}${esc(free || (used ? '' : 'no reading'))}</span></div>`;++ };++ const list = rows.map((row) => {++ const [label, kind] = FLEET_STATE[row.state] || FLEET_STATE.unknown;++ const where = (row.machines || []).map((m) => m.name || m.machine).filter(Boolean).slice(0, 3).join(', ');++ const windows = row.windows && row.windows.length ? row.windows.map(meter).join('') : '';++ // A pay-per-token agent says what is left of the money, not of a plan window.++ const credit = row.balance_usd == null ? '' : ` · $${Number(row.balance_usd).toFixed(2)} left`;++ return `<li class="c4-row c4-fleet-row"><span class="c4-row-dot${row.state === 'ok' ? ' c4-dot-ok' : row.state === 'login' || row.state === 'limited' || row.state === 'empty' ? ' c4-dot-bad' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(row.name)}</span><span class="c4-row-note">${esc(where || 'on your machine')}${row.signed_in ? '' : ' · not signed in'}${esc(credit)}${windows ? '' : ' · no limit reported'}</span>${pill(label, kind)}${windows ? `<div class="c4-fleet-windows">${windows}</div>` : ''}</li>`;++ }).join('');++ const at = latest && latest.at ? `Reported ${age(latest.at) === '0m ago' ? 'just now' : age(latest.at)}` : '';++ return `<section class="c4-card" id="fleet"><div class="c4-card-head"><h2>Your fleet</h2><span class="c4-muted">${esc(at)}</span></div>++<p class="c4-sub">What each agent on your machines has left. A machine reports it with its heartbeat; no key or account name leaves it.</p>++${problem ? note(`The fleet could not be refreshed: ${esc(problem.detail || 'Bot did not answer')}. ${latest && latest.at ? 'The last reading is below.' : ''}`, 'bad') : ''}++${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : `<div class="c4-emptyline">No machine has reported its agents yet. <a href="/machines">Pair a machine</a>, and its agents appear here while it is connected.</div>`}++</section>`;++ };++// ── Agents: the person's connections (keys live in OpenVibe.AI) ────────────────────────────────────────────const connectionsCard = async (req) => {let list = { keys: [], plans: [] };@@@ -515,7 +577,7 @@ ${rows || plans ? `<ul class="c4-rows">${rows}${plans}</ul>` : `<div class="c4-e};r.get('/agents', async (req, res) => {-- const content = `${header('Agents', 'The agents your tasks can run, and the keys behind them.')}${viewer(req) ? `${flash(req)}${await connectionsCard(req)}` : signIn(req)}`;++ const content = `${header('Agents', 'The agents your tasks can run, what they have left, and the keys behind them.')}${viewer(req) ? `${flash(req)}${await fleetCard(req, res)}${await connectionsCard(req)}` : signIn(req)}`;page(req, res, 'Agents', wrap(content));});r.post('/agents/connections', form, async (req, res) => {@@@ -21,8 +21,9 @@* `openvibe-codes task` reads from ~/.config/openvibe/token. Always the person's own rows; another person's task is 404.** The rule: the conductor may start sub-tasks on any agent except an Anthropic one. codes.task_start refuses-- * claude-code / claude-other, an Anthropic model, or no agent at all (which the router could send to Claude Code): the-- * conductor is the only Anthropic session in the system.++ * claude-code, claude-other and an Anthropic model: the conductor is the only Anthropic session in the system. A++ * sub-task with no agent named is `auto` (Codes C9c): the router reads the fleet and picks the agent with room, never++ * a fixed one and never an Anthropic one.*/const express = require('express');@@@ -39,7 +40,7 @@ const ANTHROPIC_MODEL = /(^|[^a-z])(claude|opus|sonnet|haiku|anthropic)([^a-z]|$const WAIT_MS = 20_000;const WAIT_POLL_MS = 750;--function createConductorApi({ config, store, keys, harnesses, tasks, conductor, approvals, machines = null, actorLimits, log = console }) {++function createConductorApi({ config, store, keys, harnesses, tasks, conductor, approvals, machines = null, fleet = null, actorLimits, log = console }) {const r = asyncRouter();const db = store.db;const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });@@@ -110,6 +111,26 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,} catch { return null; }}++ /**++ * The machine a conductor runs on, pinned (Codes C9c): its OpenVibe.Bot device id (`dev_…`), which is what Run++ * places a job on. Network knows the machine by its principal (`nod_…`); Bot's list names both. A machine Codes++ * cannot find there, or a Bot that does not answer, is a problem — a conductor must never fall back to running++ * wherever Run likes, because its folder and its memory are on the chosen machine.++ */++ async function pinFor(req, machine) {++ if (!fleet) return { problem: { status: 503, code: 'conductor.machine_unpinned', detail: 'Codes cannot pin a conductor to a machine here' } };++ let hit;++ try {++ hit = await fleet.deviceFor(req.person.token, machine);++ } catch (e) {++ log.warn(`[Codes] conductor: ${machine} could not be pinned: ${String(e && e.message).slice(0, 200)}`);++ return { problem: { status: 503, code: 'conductor.machine_unpinned', detail: 'OpenVibe.Bot did not answer, so Codes cannot pin the conductor to that machine; a conductor runs where its folder is, never wherever' } };++ }++ if (!hit) return { problem: { status: 422, code: 'conductor.machine_invalid', detail: 'that machine is not on openvibe.bot/machines: add it there, so a conductor can run where its folder is' } };++ if (!hit.online) return { problem: { status: 409, code: 'conductor.machine_offline', detail: `${hit.name || 'that machine'} is offline: a conductor starts on the machine its folder is on` } };++ return { device: hit.device };++ }++r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });r.post('/conductor', person, actorLimits.budget('codes.task.start'), small, async (req, res) => {@@@ -119,11 +140,13 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,const machine = typeof b.machine === 'string' && b.machine ? b.machine : null;const bad = await machineProblem(req, machine);if (bad) return problem(req, res, 422, 'conductor.machine_invalid', bad);++ const pin = machine ? await pinFor(req, machine) : { device: null };++ if (pin.problem) return problem(req, res, pin.problem.status, pin.problem.code, pin.problem.detail);const project = await projectFor(req);if (!project) return problem(req, res, 409, 'codes.no_project', 'start one task at ' + config.baseUrl + '/run first: a conductor runs in your project, on your machine');try {const made = await conductor.create({-- subject: req.person.subject, project, folder, machine,++ subject: req.person.subject, project, folder, machine, pin: pin.device,model: typeof b.model === 'string' && b.model ? b.model : 'opus',safety: typeof b.safety === 'string' && b.safety ? b.safety : 'ask',note: typeof b.note === 'string' ? b.note : null,@@@ -169,8 +192,7 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,/*** A sub-task on another harness. The rule: never an Anthropic one — that is the conductor's session alone. An-- * unnamed agent is filled in with a non-Anthropic default, because the router would otherwise be free to pick-- * Claude Code.++ * unnamed agent is `auto`: the router picks among the agents the fleet says have room, never a fixed one.*/r.post('/conductor/:id/tasks', person, actorLimits.budget('codes.task.start'), small, async (req, res) => {if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');@@@ -179,23 +201,23 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,const b = req.body && typeof req.body === 'object' ? req.body : {};const prompt = typeof b.prompt === 'string' ? b.prompt.trim() : '';if (!prompt) return problem(req, res, 422, 'codes.task_invalid', 'a sub-task needs a prompt');-- const defaultAgent = (config.conductor && config.conductor.subagentAgent) || 'codex';-- const wanted = typeof b.agent === 'string' && b.agent ? b.agent : defaultAgent;++ const named = typeof b.agent === 'string' && b.agent.trim() ? b.agent.trim() : null;++ const wanted = named && named !== 'auto' ? named : null;const model = typeof b.model === 'string' && b.model ? b.model : null;-- if (ANTHROPIC_AGENTS.has(wanted) || ANTHROPIC_MODEL.test(wanted) || (model && ANTHROPIC_MODEL.test(model))) {++ if ((wanted && (ANTHROPIC_AGENTS.has(wanted) || ANTHROPIC_MODEL.test(wanted))) || (model && ANTHROPIC_MODEL.test(model))) {return problem(req, res, 422, 'conductor.anthropic_subtask', 'the conductor may start sub-tasks on any agent except an Anthropic one: it is the only Anthropic session here');}const agent = wanted;-- if (!harnesses.list().some((h) => h.id === agent)) return problem(req, res, 422, 'codes.agent_unknown', `agent is one of GET /api/v1/harnesses, and not an Anthropic one`);++ if (agent && !harnesses.list().some((h) => h.id === agent)) return problem(req, res, 422, 'codes.agent_unknown', `agent is one of GET /api/v1/harnesses, and not an Anthropic one`);if (typeof b.repo !== 'string' || !/^https:\/\//.test(b.repo)) return problem(req, res, 422, 'codes.task_invalid', 'a sub-task needs the repository it works in (https://…)');let args;try {-- args = parseArgs(JSON.stringify({ repo: b.repo, ref: b.ref || null, prompt, agent, ...(model ? { model } : {}), permission: 'edit' }));++ args = parseArgs(JSON.stringify({ repo: b.repo, ref: b.ref || null, prompt, ...(agent ? { agent } : {}), ...(model ? { model } : {}), permission: 'edit' }));} catch (e) {return problem(req, res, 422, 'codes.task_invalid', e.message);}const task = await tasks.start({ subject: req.person.subject, project: row.project_id, args, via: 'conductor', key: null, actorRef: { conductor: { task_id: row.id } } });-- log.info(`[Codes] conductor ${row.id} started sub-task ${task.id} on ${agent}`);++ log.info(`[Codes] conductor ${row.id} started sub-task ${task.id} on ${agent || 'auto'}`);return res.status(201).json({ task: { ...tasks.present(task), outcome: { state: task.outcome || null, note: task.outcome_note || null } } });});@@@ -18,9 +18,9 @@ const SERVICES_ORIGIN = 'https://openvibe.services';const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/tools', '/manifests', '/staff'];const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];// The console's API: everything under /api/v1 but the agent catalog, the coding tasks (codes.task.*), the person's--// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3) and the conductor--// with its approvals inbox (Codes C9b).--const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me|connections|conductor|approvals)(\/|$)/;++// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor++// with its approvals inbox (Codes C9b) and the fleet their machines report (Codes C9c).++const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me|connections|conductor|approvals|fleet)(\/|$)/;const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);@@@ -39,7 +39,7 @@ const { expandMatrix } = require('../domain/bench');const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;--function createTasksApi({ config, store, keys, run, harnesses, actorLimits, tasks = null, log = console }) {++function createTasksApi({ config, store, keys, run, harnesses, actorLimits, tasks = null, fleet = null, log = console }) {const r = asyncRouter();const db = store.db;const taskStore = tasks || createRunTasks({ store, run, config, log });@@@ -135,6 +135,34 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task};};++ /**++ * The fleet (Codes C9c): each of the person's machines' agents and limit windows, read from OpenVibe.Bot with the++ * person's own token and kept here. A signed-in visitor or their own access token; never a service acting for++ * someone (a machine's windows are the person's own account).++ */++ r.get('/fleet', actorLimits.reads('codes.read'), async (req, res) => {++ let subject = null;++ let token = null;++ if (signedIn(req)) { subject = req.viewer.subject; token = req.viewer.token || null; }++ else {++ const header = String(req.headers.authorization || '');++ if (header.startsWith('Bearer ') && !principalToken(header.slice(7).trim())) {++ token = header.slice(7).trim();++ subject = await personOf(token);++ }++ }++ if (!subject) return problem(req, res, 401, 'token.required', 'sign in, or send your own Network access token, to read your fleet');++ if (!fleet) return res.json({ ok: false, machines: [], agents: [], problem: { status: 503, detail: 'the fleet is not configured here' } });++ let out;++ try { out = await fleet.refresh(subject, token); } catch (e) {++ if (e.status === 401) return problem(req, res, 401, 'token.invalid', 'the session expired: sign in again');++ out = { ok: false, problem: { status: e.status || 503, detail: String(e.message || 'the fleet could not be read').slice(0, 300) } };++ }++ const agents = await fleet.agents(subject, harnesses);++ const latest = await fleet.latest(subject);++ return res.json({ ok: out.ok !== false, machines: latest.machines, agents, at: latest.at, ...(out.problem ? { problem: out.problem } : {}) });++ });++r.post('/tasks', guardAny('codes.task.start'), actorLimits.budget('codes.task.start'), json, async (req, res) => {const b = req.body;// Actor back-reference: the contract does not carry these yet, so they are stripped before validation and stored@@@ -55,6 +55,9 @@ async function startNetworkStub() {await db.exec("INSERT INTO codes_outbox (event_id, topic, subject, payload) VALUES ($1, 'codes.task.updated', $2, '{}'::jsonb)", [`evt_${ids.ulid()}`, subject]);await db.exec("INSERT INTO telemetry_samples (at, service, operation, subject) VALUES (now(), 'codes', 'task.start', $1)", [subject]);await db.exec("INSERT INTO codes_events_inbox (event_id, event_type, task_id) VALUES ($1, 'run.job.finished', $2)", [`evt_${ids.ulid()}`, taskId]);++ await db.exec("INSERT INTO codes_notices (task_id, kind) VALUES ($1, 'done')", [taskId]);++ await db.exec(`INSERT INTO codes_machine_fleet (subject, machine, name, agent, installed, signed_in, windows, online, reported_at)++ VALUES ($1, 'dev_laptop', 'laptop', 'codex', true, true, '[]'::jsonb, true, now())`, [subject]);}const deliver = async (event, { secret = SECRET } = {}) => {const body = JSON.stringify({ event, seq: 1 });@@@ -103,6 +106,10 @@ async function startNetworkStub() {assert.ok(telemetry, 'telemetry.json is in the export (the SDK covers the table)');assert.strictEqual(telemetry.content.length, 1, 'Ada’s sample only');assert.deepStrictEqual(Object.keys(telemetry.content[0]).sort(), ['at', 'latency_ms', 'operation', 'resource', 'service', 'status']);++ const fleet = part.body.files.find((x) => x.name === 'fleet.json');++ assert.ok(fleet, 'fleet.json is in the export');++ assert.strictEqual(fleet.content.length, 1, 'Ada’s fleet only');++ assert.strictEqual(fleet.content[0].agent, 'codex');});await check('a deletion erases them, keeps everyone else’s, and confirms once', async () => {@@@ -117,6 +124,7 @@ async function startNetworkStub() {assert.strictEqual(confirmations[0].body.erased.run_tasks, 1);for (const [table, key, who] of [['codes_attempts', 'task_id', [boTask]], ['codes_task_proof', 'task_id', [boTask]], ['codes_events_inbox', 'task_id', [boTask]],++ ['codes_notices', 'task_id', [boTask]], ['codes_machine_fleet', 'subject', [bo]],['codes_run_tokens', 'subject', [bo]], ['codes_user_settings', 'subject', [bo]], ['codes_outbox', 'subject', [bo]],]) {const left = (await db.many(`SELECT ${key} FROM ${table}`)).map((r) => r[key]);@@@ -76,6 +76,8 @@ function mcpChild(env) {const db = t.ctx.store.db;const ada = t.network.addUser('ada');t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });++ // The machine, as OpenVibe.Bot answers it: the conductor is pinned to its device id, so Run never moves it.++ t.bot.addMachine({ id: 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2', node_id: NODE, name: 'ada-laptop', online: true });const created = await t.get('/api/v1/conductor', { as: ada, json: { folder: '/home/ada/openvibe', machine: NODE, safety: 'ask' } });assert.strictEqual(created.status, 201, created.text);nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++++/**++ * The Agents page shows the fleet (Codes C9c): one calm row per agent, what each machine reports with its heartbeat —++ * a meter per limit window, and when it frees.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const ada = t.network.addUser('ada');++ const RESET = '2026-11-04T00:00:00Z';++ // The machine, as OpenVibe.Bot answers it: the page reads Bot and keeps the latest reading per machine.++ t.bot.addMachine({++ id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet_at: new Date().toISOString(),++ fleet: [++ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', used: 96, limit: 100, pct: 96, resets_at: RESET }] },++ { agent: 'minimax-code', installed: true, signed_in: true, windows: [] },++ { agent: 'opencode', installed: true, signed_in: false, windows: [] },++ ],++ });++ await t.ctx.store.db.exec(++ `INSERT INTO codes_machine_fleet (subject, machine, node_id, name, agent, installed, signed_in, windows, online, reported_at, updated_at)++ VALUES ($1,'dev_ada_laptop','nod_ada','ada-laptop','codex',true,true,$2::jsonb,true,now(),now()),++ ($1,'dev_ada_laptop','nod_ada','ada-laptop','minimax-code',true,true,'[]'::jsonb,true,now(),now()),++ ($1,'dev_ada_laptop','nod_ada','ada-laptop','opencode',true,false,'[]'::jsonb,true,now(),now())`,++ [ada.subject, JSON.stringify([{ name: 'week', used: 96, limit: 100, pct: 96, resets_at: RESET }])],++ );++++ try {++ await check('the Agents page lists each agent of the fleet with a meter and when its window frees', async () => {++ const r = await t.get('/agents', { as: ada });++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('Your fleet'), 'the fleet card');++ assert.ok(r.text.includes('c4-fleet-row'), 'a row per agent');++ assert.ok(r.text.includes('c4-meter'), 'a meter per window');++ assert.ok(r.text.includes('c4-meter-fill is-full'), 'a full window is drawn full');++ assert.ok(r.text.includes('week'), 'the window is named');++ assert.ok(/frees (now|in \d+ (min|h|d)) · Wed 00:00 UTC/.test(r.text), 'when it frees: how soon, and the time in UTC');++ assert.ok(r.text.includes('<b>96 of 100</b>'), 'how much is used');++ assert.ok(r.text.includes('ada-laptop · no limit reported'), 'an agent without a window says so beside its machine, not as an empty meter');++ assert.ok(!r.text.includes('No limit window'), 'no empty meter row');++ assert.ok(r.text.includes('ada-laptop'), 'the machine that reported it');++ });++++ await check('each agent gets the state a person reads: full, has room, or sign in', async () => {++ const r = await t.get('/agents', { as: ada });++ assert.ok(r.text.includes('>Full<'), 'Codex at 96% is full');++ assert.ok(r.text.includes('>Has room<'), 'MiniMax has room');++ assert.ok(r.text.includes('>Sign in<'), 'OpenCode is not signed in');++ assert.ok(r.text.includes('not signed in'));++ });++++ await check('with nothing reported the page says so and links to pairing a machine', async () => {++ const bo = t.network.addUser('bo');++ t.bot.state.machines.length = 0; // no machine of bo's is connected++ const r = await t.get('/agents', { as: bo });++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('No machine has reported its agents yet'));++ assert.ok(r.text.includes('/machines'));++ });++ } catch (e) {++ console.error(e);++ process.exitCode = 1;++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,174 @@++'use strict';++++/**++ * Codes C9c, end to end: a task with no agent named is `auto`, and the router reads the fleet the machines reported.++ * Today (2026-10-30) Codex's week is spent, OpenCode's month is spent and Command Code is at 96% of its week, so auto++ * must land on MiniMax or DeepSeek — and never on Claude. A finished or unfinished task then tells the person once.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N3';++const RESET_WEEK = '2026-11-04T00:00:00Z';++const RESET_MONTH = '2026-11-02T00:00:00Z';++++(async () => {++ const submits = [];++ let counter = 0;++ const run = {++ enabled: true,++ async submit(o) { counter++; submits.push(o); return { id: `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J${String(counter).padStart(2, '0')}`, state: 'queued' }; },++ async read(jobId) { return { id: jobId, state: 'queued' }; },++ async cancel() { return { state: 'cancelled' }; },++ async streamUrl() { throw new Error('not used'); },++ };++ const pushed = [];++ const notify = { async push(body) { pushed.push(body); return { sent: true }; } };++ // DeepSeek's balance is read the way the portal reads it (engine/balance.js): $0.02 today, too little for a job.++ const t = await boot({++ run, notify,++ balance: async () => ({ deepseek: { usd: 0.02, at: new Date().toISOString() } }),++ // The fleet is read fresh every time here, so a changed reading is the one the router sees.++ env: { CODES_FLEET_TTL_MS: '0' },++ });++ const db = t.ctx.store.db;++ const ada = t.network.addUser('ada');++ t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });++++ /** The fleet Ada's machines reported with their last heartbeats. */++ const window = (name, pct, resetsAt) => JSON.stringify([{ name, pct, resets_at: resetsAt }]);++ // The machine, as OpenVibe.Bot answers it: the device id Run pins a job to, the Network id the machine is known++ // by, and the fleet its Node last reported with its heartbeat.++ t.bot.addMachine({++ id: 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1', node_id: NODE, name: 'ada-laptop', online: true, fleet_at: new Date().toISOString(),++ fleet: [++ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET_WEEK }] },++ { agent: 'opencode', installed: true, signed_in: true, windows: [{ name: 'month', pct: 100, resets_at: RESET_MONTH }] },++ { agent: 'command-code', installed: true, signed_in: true, windows: [{ name: 'week', pct: 96, resets_at: RESET_WEEK }] },++ { agent: 'minimax-code', installed: true, signed_in: true, windows: [] },++ { agent: 'deepseek', installed: true, signed_in: true, windows: [] },++ ],++ });++ await db.exec(++ `INSERT INTO codes_machine_fleet (subject, machine, node_id, name, agent, installed, signed_in, windows, online, reported_at, updated_at)++ VALUES ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','codex',true,true,$2::jsonb,true,now(),now()),++ ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','opencode',true,true,$3::jsonb,true,now(),now()),++ ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','command-code',true,true,$4::jsonb,true,now(),now()),++ ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','minimax-code',true,true,'[]'::jsonb,true,now(),now()),++ ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','deepseek',true,true,'[]'::jsonb,true,now(),now())`,++ [ada.subject, window('week', 100, RESET_WEEK), window('month', 100, RESET_MONTH), window('week', 96, RESET_WEEK)],++ );++++ const start = async (prompt) => {++ const r = await t.get('/api/v1/me/tasks', { as: ada, json: { repo: 'https://github.com/octocat/Hello-World', prompt, idempotency_key: `c9c_${Math.random().toString(36).slice(2, 10)}` } });++ assert.strictEqual(r.status, 201, r.text);++ return r.json().task;++ };++ const endAttempt = async (taskId, { state, result = null }) => {++ const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [taskId]);++ const updated = await t.ctx.tasks.applyEvent(row, { state, job: { id: row.job_id, state, result, error: null }, error: null });++ await t.ctx.tasks.afterEnd(updated.id);++ };++++ try {++ await check('auto: today\'s fleet never starts a task on Codex, OpenCode or Command Code', async () => {++ const hard = await start('Find the root cause of the race condition in the payment webhook and prove it with a test.');++ assert.strictEqual(submits.at(-1).args.agent, 'minimax-code', 'Codex is spent, so MiniMax takes the hard task');++ assert.strictEqual(hard.agent, 'minimax-code');++ const basic = await start('Rename the variable in the one file and reformat it.');++ assert.strictEqual(submits.at(-1).args.agent, 'minimax-code', 'OpenCode is spent and Command Code is at 96%');++ assert.strictEqual(basic.agent, 'minimax-code');++ for (const s of submits) assert.ok(!/claude/.test(String(s.args.agent)), 'never an Anthropic agent');++ });++++ await check('a sub-task the conductor starts without an agent is auto too', async () => {++ const created = await t.get('/api/v1/conductor', { as: ada, json: { folder: '/home/ada/openvibe', machine: NODE } });++ assert.strictEqual(created.status, 201, created.text);++ const before = submits.length;++ const r = await t.get(`/api/v1/conductor/${created.json().task.id}/tasks`, { as: ada, json: { prompt: 'Fix the parser and add a test.', repo: 'https://github.com/octocat/Hello-World' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(submits.length, before + 1);++ assert.strictEqual(submits.at(-1).args.agent, 'minimax-code', 'the router picked, not a fixed codex');++ assert.strictEqual(r.json().task.agent, 'minimax-code');++ });++++ await check('a finished task tells the person once, and a redelivered ending does not tell them again', async () => {++ const task = await start('Add the endpoint and its tests.');++ await endAttempt(task.id, { state: 'succeeded', result: { ok: true, summary: 'It added the endpoint and its tests.', files: [], log: [{ t: 'done', ok: true, text: 'done' }] } });++ const told = pushed.filter((p) => p.title === 'Your task is done' && p.url.endsWith(`/tasks/${task.id}`));++ assert.strictEqual(told.length, 1, JSON.stringify(pushed));++ assert.match(told[0].message, /endpoint/);++ await t.ctx.tasks.afterEnd(task.id);++ assert.strictEqual(pushed.filter((p) => p.url.endsWith(`/tasks/${task.id}`)).length, 1, 'one notice per task and state');++ });++++ await check('a task that needs a person says so, high priority', async () => {++ const task = await start('Rename a variable in the one file.');++ // The first failure is a second opinion on another agent; when that one fails too, the task needs a person.++ await endAttempt(task.id, { state: 'failed', result: { ok: false, summary: 'I could not finish it', files: [], log: [{ t: 'done', ok: false, text: 'I could not finish it' }] } });++ await endAttempt(task.id, { state: 'failed', result: { ok: false, summary: 'I could not finish it either', files: [], log: [{ t: 'done', ok: false, text: 'I could not finish it either' }] } });++ const row = await db.one('SELECT outcome, outcome_note FROM run_tasks WHERE id = $1', [task.id]);++ assert.strictEqual(row.outcome, 'unfinished', row.outcome_note);++ const told = pushed.filter((p) => p.title === 'Your task needs you' && p.url.endsWith(`/tasks/${task.id}`));++ assert.strictEqual(told.length, 1, JSON.stringify(pushed));++ assert.strictEqual(told[0].priority, 'high');++ });++++ await check('a task the person stopped tells them nothing', async () => {++ const task = await start('Rename a variable in the one file now.');++ await endAttempt(task.id, { state: 'cancelled' });++ assert.strictEqual(pushed.filter((p) => p.url.endsWith(`/tasks/${task.id}`)).length, 0);++ });++++ await check('a task that no agent may take is refused, and needs a person', async () => {++ // Every flat plan is spent and DeepSeek has $0.02: nothing may take it, and the task says why.++ t.bot.state.machines[0].fleet = [++ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET_WEEK }] },++ { agent: 'opencode', installed: true, signed_in: true, windows: [{ name: 'month', pct: 100, resets_at: RESET_MONTH }] },++ { agent: 'command-code', installed: true, signed_in: true, windows: [{ name: 'week', pct: 96, resets_at: RESET_WEEK }] },++ { agent: 'minimax-code', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET_WEEK }] },++ { agent: 'deepseek', installed: true, signed_in: true, windows: [] },++ ];++ await t.get('/api/v1/fleet', { as: ada }); // the reading Bot now answers is the one Codes keeps++ const before = submits.length;++ const r = await t.get('/api/v1/me/tasks', { as: ada, json: { repo: 'https://github.com/octocat/Hello-World', prompt: 'Find the root cause of the race condition in the payment webhook.', idempotency_key: `c9c_${Math.random().toString(36).slice(2, 10)}` } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(submits.length, before, 'no job was started');++ const row = await db.one('SELECT state, error, outcome FROM run_tasks WHERE id = $1', [r.json().task.id]);++ assert.strictEqual(row.state, 'failed');++ assert.match(row.error, /no agent has room/, row.error);++ assert.match(row.error, /DeepSeek has \$0\.02 left/, row.error);++ const told = pushed.filter((p) => p.title === 'Your task needs you' && p.url.endsWith(`/tasks/${r.json().task.id}`));++ assert.strictEqual(told.length, 1, 'the person is told, once');++ });++++ await check('/api/v1/fleet answers the stored fleet for the person', async () => {++ // Ada's machine reports again: MiniMax has room, DeepSeek's balance is what it is.++ t.bot.state.machines[0].fleet = [++ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET_WEEK }] },++ { agent: 'opencode', installed: true, signed_in: true, windows: [{ name: 'month', pct: 100, resets_at: RESET_MONTH }] },++ { agent: 'command-code', installed: true, signed_in: true, windows: [{ name: 'week', pct: 96, resets_at: RESET_WEEK }] },++ { agent: 'minimax-code', installed: true, signed_in: true, windows: [] },++ { agent: 'deepseek', installed: true, signed_in: true, windows: [] },++ ];++ const r = await t.get('/api/v1/fleet', { as: ada });++ assert.strictEqual(r.status, 200, r.text);++ const body = r.json();++ assert.ok(Array.isArray(body.machines) && body.machines.length === 1);++ assert.ok(Array.isArray(body.agents));++ const mm = body.agents.find((a) => a.agent === 'minimax-code');++ assert.strictEqual(mm.state, 'ok');++ const ds = body.agents.find((a) => a.agent === 'deepseek');++ assert.strictEqual(ds.balance_usd, 0.02, 'the balance the portal reads, in the agent\'s own row');++ assert.strictEqual(ds.state, 'empty', 'and an agent out of credit says so');++ assert.ok(mm.machines.some((m) => m.machine === 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1'));++ assert.strictEqual(body.problem, undefined, 'Bot answered, so there is no problem beside the fleet');++ });++ } catch (e) {++ console.error(e);++ process.exitCode = 1;++ } finally {++ await t.close();++ }++ done();++})();@@@ -13,6 +13,7 @@ const { boot, check, done } = require('./helpers/boot');const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';++const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';(async () => {const calls = [];@@@ -36,6 +37,10 @@ const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';const ada = t.network.addUser('ada');const bo = t.network.addUser('bo');t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });++ // The same machine on OpenVibe.Bot (Codes C9c): the device id is what Run places a job on, the node id what++ // Network knows it by. A conductor's turns are pinned to it.++ const mineBot = () => t.bot.addMachine({ id: DEVICE, node_id: NODE, name: 'ada-laptop', online: true });++ mineBot();const submits = () => calls.filter((c) => c[0] === 'submit');const get = (path, as = ada) => t.get(path, { as });@@@ -91,6 +96,8 @@ const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';assert.strictEqual(submit.args.mcp, true);assert.strictEqual(submit.args.resume, undefined, 'the first turn starts the session');assert.ok(submit.limits && submit.limits.wall_ms >= 60_000, 'a turn gets a wall time');++ assert.strictEqual(submit.node, DEVICE, 'the turn is pinned to the chosen machine, by its OpenVibe.Bot device id');++ assert.strictEqual(row.pin, DEVICE, 'the pin is kept on the conductor');});await check('a message waits while a turn runs, then is the next turn of the same session', async () => {@@@ -111,6 +118,7 @@ const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';const submit = submits().at(-1)[1];assert.strictEqual(submit.args.resume, 'sess_abc', 'the next turn resumes the conversation');assert.strictEqual(submit.args.prompt, 'now do the second piece');++ assert.strictEqual(submit.node, DEVICE, 'every turn of the session is pinned to the same machine');});await check('a turn that ends with nothing queued leaves the conductor idle, not finished', async () => {@@@ -125,6 +133,32 @@ const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';assert.strictEqual(view.messages.at(-1).result.summary, 'Everything is in order.');});++ await check('a conductor is started only where its folder is: no such machine on Bot, away, or Bot itself away', async () => {++ const other = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2';++ t.network.addNode(ada.subject, { principal: other, node_id: 'node_ada_desktop', name: 'ada-desktop', status: 'active', last_seen_at: new Date().toISOString() });++ const before = submits().length;++ const unknown = await post('/api/v1/conductor', { folder: '/home/ada/openvibe', machine: other });++ assert.strictEqual(unknown.status, 422, unknown.text);++ assert.strictEqual(unknown.json().code, 'conductor.machine_invalid', 'a machine Codes cannot find on Bot is never a guess');++ t.bot.addMachine({ id: 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2', node_id: other, name: 'ada-desktop', online: false });++ const away = await post('/api/v1/conductor', { folder: '/home/ada/openvibe', machine: other });++ assert.strictEqual(away.status, 409, away.text);++ assert.strictEqual(away.json().code, 'conductor.machine_offline');++ t.bot.setDown(true);++ const down = await post('/api/v1/conductor', { folder: '/home/ada/openvibe', machine: NODE });++ assert.strictEqual(down.status, 503, down.text);++ assert.strictEqual(down.json().code, 'conductor.machine_unpinned');++ assert.strictEqual(submits().length, before, 'no job was started for a conductor that could not be pinned');++++ // The pin is kept on the conductor: a later turn starts on the same machine without asking Bot again.++ const res = await post(`/api/v1/conductor/${taskId}/messages`, { text: 'now the next piece' });++ t.bot.setDown(false);++ assert.strictEqual(res.status, 201, res.text);++ assert.strictEqual(submits().at(-1)[1].node, DEVICE, 'the stored pin starts the turn, even with Bot away');++ const ended = await endTurn(taskId, { session: 'sess_abc' });++ assert.strictEqual(ended.state, 'succeeded', 'idle again');++ });++await check('a failing turn is unfinished, and a message still carries on', async () => {const res = await post(`/api/v1/conductor/${taskId}/messages`, { text: 'try once more' });assert.strictEqual(res.status, 201, res.text);@@@ -146,8 +180,11 @@ const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';assert.strictEqual(started.status, 201, started.text);const submit = submits().at(-1)[1];assert.strictEqual(submits().length, before + 1);-- assert.strictEqual(submit.args.agent, 'codex', 'an unnamed agent is a non-Anthropic one');++ // An unnamed agent is auto (Codes C9c): the router names one from the fleet, and never an Anthropic one.++ assert.strictEqual(submit.args.agent, 'opencode', 'an unnamed agent is the one the router picked, from a non-Anthropic ladder');++ assert.ok(!/claude/.test(String(submit.args.agent)), 'never an Anthropic agent');assert.ok(!submit.args.push, 'a sub-task does not push');++ assert.strictEqual(submit.node, undefined, 'a sub-task is placed by Run wherever it fits, not pinned');const sub = started.json().task;assert.notStrictEqual(sub.id, taskId, 'a sub-task is its own task');const subRow = await rowOf(sub.id);nnew file mode 100644@@@ -0,0 +1,77 @@++'use strict';++++/**++ * What an agent's account has left, read the way the portal reads it (Codes C9c): the key is the person's own file,++ * the call is DeepSeek's own balance endpoint, and the number is `balance_infos[0].total_balance`. Nothing else is read,++ * and nothing else leaves: the key goes to DeepSeek and only the number comes back.++ */++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const { check, done } = require('./helpers/boot');++const { readBalance, readBalances, keyOf, ACCOUNTS } = require('../engine/balance');++++/** A home with a DeepSeek key in it, and a fetch that answers like DeepSeek does. */++function withKey(body, run) {++ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-balance-'));++ fs.mkdirSync(path.join(home, '.config', 'deepseek'), { recursive: true });++ fs.writeFileSync(path.join(home, '.config', 'deepseek', 'api_key'), body);++ return run(home);++}++++(async () => {++ try {++ await check('the key is the file the CLI keeps it in, and nothing else', () => {++ withKey(' sk-secret\n', (home) => {++ assert.equal(keyOf(ACCOUNTS.deepseek, { home }), 'sk-secret');++ assert.equal(keyOf(ACCOUNTS.deepseek, { home: path.join(home, 'nowhere') }), null, 'no file, no key');++ });++ const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-balance-'));++ fs.writeFileSync(path.join(empty, 'key'), ' \n');++ assert.equal(keyOf(ACCOUNTS.deepseek, { home: empty }), null, 'a blank file is no key');++ });++++ await check('the balance is what DeepSeek says it is', async () => {++ const seen = [];++ const fetchImpl = async (url, opts) => {++ seen.push({ url, auth: opts.headers.Authorization });++ return { ok: true, json: async () => ({ balance_infos: [{ total_balance: '0.02' }, { total_balance: '5.00' }] }) };++ };++ const out = await withKey('sk-secret', (home) => readBalance('deepseek', { home, fetchImpl }));++ assert.equal(out.usd, 0.02, 'the first balance is the account\'s');++ assert.ok(out.at, 'and when it was read');++ assert.equal(seen.length, 1);++ assert.equal(seen[0].url, 'https://api.deepseek.com/user/balance', 'the portal\'s own call');++ assert.equal(seen[0].auth, 'Bearer sk-secret', 'with the person\'s own key');++ });++++ await check('an answer that says nothing about a balance is unknown, never zero', async () => {++ const noKey = await readBalance('deepseek', { home: os.tmpdir(), fetchImpl: async () => ({ ok: true, json: async () => ({}) }) });++ assert.equal(noKey, null, 'no key, no balance');++ await withKey('sk-secret', async (home) => {++ const empty = await readBalance('deepseek', { home, fetchImpl: async () => ({ ok: true, json: async () => ({ balance_infos: [] }) }) });++ assert.equal(empty, null, 'no balance_infos, no balance');++ const broken = await readBalance('deepseek', { home, fetchImpl: async () => ({ ok: false, status: 402, json: async () => ({}) }) });++ assert.equal(broken, null, 'a refused call is no balance');++ const down = await readBalance('deepseek', { home, fetchImpl: async () => { throw new Error('unreachable'); } });++ assert.equal(down, null, 'a call that does not answer is no balance');++ const nan = await readBalance('deepseek', { home, fetchImpl: async () => ({ ok: true, json: async () => ({ balance_infos: [{ total_balance: 'nope' }] }) }) });++ assert.equal(nan, null, 'a number that is not a number is no balance');++ });++ });++++ await check('every account is read, and the ones that cannot be are absent', async () => {++ const out = await withKey('sk-secret', (home) => readBalances({++ home,++ fetchImpl: async () => ({ ok: true, json: async () => ({ balance_infos: [{ total_balance: 12.5 }] }) }),++ }));++ assert.equal(out.deepseek.usd, 12.5);++ assert.deepEqual(Object.keys(out), ['deepseek'], 'an account with no key is absent, not zero');++ });++ } catch (e) {++ console.error(e);++ process.exitCode = 1;++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++++/**++ * The router on live quotas (Codes C9c). The fleet is what the person's machines reported with their heartbeats, and++ * the router skips an agent whose window is full or that is not signed in — so `auto` never picks an agent that would++ * stop mid-way. Today's state (2026-10-30) is in these tests: Codex's week is spent until 11-04, OpenCode's month++ * until 11-02, and Command Code is at 96% of its week; auto must route to MiniMax or DeepSeek.++ */++const assert = require('node:assert/strict');++const { check, done } = require('./helpers/boot');++const router = require('../engine/router');++++const NOW = Date.parse('2026-10-30T12:00:00Z');++const RESET_WEEK = '2026-11-04T00:00:00Z';++const RESET_MONTH = '2026-11-02T00:00:00Z';++++const runner = (enabled = true) => ({ enabled, max: 4 });++const cfg = { subagents: { cx: runner(), mm: runner(), cc: runner(), oc: runner(), dsh: runner(), claude: runner(false) } };++const health = { cx: { state: 'ok' }, mm: { state: 'ok' }, cc: { state: 'ok' }, oc: { state: 'ok' }, dsh: { state: 'ok' } };++const context = (fleet) => ({ cfg, health, counts: {}, models: {}, fleet });++++/** Today's state of the owner's plans, as the Nodes report it. */++const TODAY = {++ cx: { signed_in: true, installed: true, windows: [{ name: 'week', used: 100, limit: 100, pct: 100, resets_at: RESET_WEEK }] },++ oc: { signed_in: true, installed: true, windows: [{ name: 'month', used: 100, limit: 100, pct: 100, resets_at: RESET_MONTH }] },++ cc: { signed_in: true, installed: true, windows: [{ name: 'week', used: 96, limit: 100, pct: 96, resets_at: RESET_WEEK }] },++ mm: { signed_in: true, installed: true, windows: [] },++ dsh: { signed_in: true, installed: true, windows: [] },++};++++const HARD = 'Root cause of the race condition in the payment webhook. Find the deadlock and write the test that proves it.';++const BASIC = 'Rename the variable in the one file and reformat it.';++++(async () => {++ try {++ await check('a window at the full mark is spent, and one that has already reset is not', () => {++ assert.equal(router.windowFull({ pct: 96 }, NOW), true, '96% of a week is spent, not 4% left');++ assert.equal(router.windowFull({ used: 19, limit: 20 }, NOW), true, 'used/limit is the same reading');++ assert.equal(router.windowFull({ pct: 100, resets_at: '2026-10-29T00:00:00Z' }, NOW), false, 'it freed yesterday');++ assert.equal(router.windowFull({ pct: 40 }, NOW), false);++ assert.equal(router.windowFull(null, NOW), false);++ assert.equal(router.FULL_PCT, 95);++ });++++ await check('room needs a signed-in agent with no full window', () => {++ assert.equal(router.roomFor(TODAY, 'mm', NOW), true);++ assert.equal(router.roomFor(TODAY, 'cx', NOW), false);++ assert.equal(router.roomFor(TODAY, 'cc', NOW), false, '96% is full');++ assert.equal(router.roomFor({ cx: { signed_in: false, windows: [] } }, 'cx', NOW), false, 'not signed in');++ assert.equal(router.roomFor({}, 'cx', NOW), true, 'an agent the fleet does not name is unknown, not full');++ assert.equal(router.roomFor(null, 'cx', NOW), true, 'no fleet at all leaves the ladder as it was');++ });++++ await check('auto routes today to MiniMax or DeepSeek, never Codex, OpenCode or Command Code', () => {++ for (const prompt of ['Standard feature: add the endpoint and its tests.', HARD, BASIC, 'Design pass on the task page: layout, css and a screenshot at 390.']) {++ const picked = router.pick(prompt, context(TODAY));++ assert.ok(picked, `something takes: ${prompt}`);++ assert.ok(['mm', 'dsh'].includes(picked.runner), `${prompt} → ${picked.runner}`);++ }++ assert.equal(router.pick(HARD, context(TODAY)).runner, 'mm', 'hard starts at Codex, which is spent, so MiniMax');++ assert.equal(router.pick(BASIC, context(TODAY)).runner, 'mm', 'basic starts at OpenCode (spent), Command Code (96%), then MiniMax');++ });++++ await check('a plan with room keeps its place at the front of the ladder', () => {++ const room = { ...TODAY, cx: { signed_in: true, windows: [{ name: 'week', pct: 20, resets_at: RESET_WEEK }] } };++ assert.equal(router.pick(HARD, context(room)).runner, 'cx', 'Codex with room takes the hard task');++ const ccRoom = { ...TODAY, cc: { signed_in: true, windows: [{ name: 'week', pct: 40, resets_at: RESET_WEEK }] } };++ assert.equal(router.pick(BASIC, context(ccRoom)).runner, 'cc');++ });++++ await check('a flat-price plan with room is preferred over a pay-per-token agent', () => {++ // Both have room: the plan comes first, because the pay-per-token one costs money for every token.++ const both = { ...TODAY, mm: { signed_in: true, windows: [] }, dsh: { signed_in: true, windows: [] } };++ assert.equal(router.pick(BASIC, context(both)).runner, 'mm', 'the plan, not the meter');++ assert.equal(router.pick(HARD, context(both)).runner, 'mm');++ // And when the plan is spent, the pay-per-token one is what is left.++ const spent = { ...both, mm: { signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET_WEEK }] } };++ assert.equal(router.pick(BASIC, context(spent)).runner, 'dsh', 'DeepSeek is last, but it is the one with room');++ });++++ await check('an agent the person is not signed in to is skipped for one that is', () => {++ const out = { ...TODAY, cx: { signed_in: false, windows: [] }, mm: { signed_in: false, windows: [] } };++ assert.equal(router.pick(HARD, context(out)).runner, 'dsh', 'Codex and MiniMax are signed out, so DeepSeek');++ });++++ await check('an agent whose balance is nearly gone may not take a job', () => {++ // DeepSeek reads its balance the way the portal reads it (engine/balance.js): $0.02 today.++ assert.equal(router.MIN_BALANCE_USD, 1, 'a balance under a dollar would not pay for the job');++ const broke = { ...TODAY, dsh: { ...TODAY.dsh, balance_usd: 0.02 } };++ assert.equal(router.balanceLow(broke, 'dsh'), true, '$0.02 is too little');++ assert.equal(router.roomFor(broke, 'dsh', NOW), false, 'so DeepSeek has no room');++ assert.equal(router.pick(HARD, context(broke)).runner, 'mm', 'and the hard task goes to MiniMax');++ const signedOut = { ...broke, cx: { signed_in: false, windows: [] }, mm: { signed_in: false, windows: [] } };++ assert.equal(router.pick(HARD, context(signedOut)), null, 'nothing may take it: no agent is left, not a guess');++++ // A balance nobody read is unknown, not empty: the ladder keeps working.++ assert.equal(router.balanceLow(TODAY, 'dsh'), false);++ assert.equal(router.roomFor(TODAY, 'dsh', NOW), true);++ assert.equal(router.pick(HARD, context(TODAY)).runner, 'mm');++ // And a balance with money in it is no reason to skip anything.++ const paid = { ...TODAY, dsh: { ...TODAY.dsh, balance_usd: 12.5 } };++ assert.equal(router.roomFor(paid, 'dsh', NOW), true);++ });++ } catch (e) {++ console.error(e);++ process.exitCode = 1;++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,161 @@++'use strict';++++/**++ * The fleet (Codes C9c): what each of a person's machines reports with its heartbeat, kept per machine and answered for++ * the Agents page, the router and the MCP tool. Bot is stood in for here; the database is the real one.++ */++const assert = require('node:assert/strict');++const { check, done } = require('./helpers/boot');++const { createStore } = require('../server/db');++const { createFleet } = require('../server/domain/fleet');++const { testDb } = require('./helpers/db');++++const SUBJECT = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2AB';++const NOW = Date.parse('2026-10-30T12:00:00Z');++const RESET = '2026-11-04T00:00:00Z';++++/** Bot's answer for one person's machines. */++const machines = (list) => ({ ok: true, status: 200, json: async () => ({ machines: list }) });++const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });++++(async () => {++ const testdb = await testDb();++ const store = createStore(testdb.db, { now: () => NOW });++ const log = { warn() {} };++++ try {++ await check('the heartbeat report is stored per machine and agent, and answered per machine', async () => {++ let doc = machines([++ { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [++ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', used: 100, limit: 100, resets_at: RESET }] },++ { agent: 'minimax-code', installed: true, signed_in: true, windows: [] },++ ] },++ { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: false, fleet: [++ { agent: 'opencode', installed: true, signed_in: false, windows: [] },++ ] },++ ]);++ const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => doc });++ const out = await fleet.refresh(SUBJECT, 'tok');++ assert.equal(out.ok, true);++ assert.equal(out.machines.length, 2);++ const laptop = out.machines.find((m) => m.id === 'dev_ada_laptop');++ assert.equal(laptop.node_id, 'nod_ada');++ assert.equal(laptop.agents.length, 2);++ assert.equal(laptop.agents[0].windows[0].pct, 100, 'the pct is derived from used/limit');++ assert.equal(laptop.agents[0].windows[0].resets_at, new Date(RESET).toISOString());++ const desk = out.machines.find((m) => m.id === 'dev_ada_desk');++ assert.equal(desk.online, false);++ });++++ await check('the page rows: one per agent, a state each, usable first', async () => {++ const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([++ { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [++ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', used: 100, limit: 100, resets_at: RESET }] },++ { agent: 'minimax-code', installed: true, signed_in: true, windows: [{ name: 'week', used: 10, limit: 100, resets_at: RESET }] },++ { agent: 'command-code', installed: true, signed_in: true, windows: [{ name: 'week', pct: 84, resets_at: RESET }] },++ { agent: 'opencode', installed: true, signed_in: false, windows: [] },++ { agent: 'deepseek', installed: false, signed_in: false, windows: [] },++ ] },++ ]) });++ await fleet.refresh(SUBJECT, 'tok');++ const rows = await fleet.agents(SUBJECT, null);++ const by = Object.fromEntries(rows.map((r) => [r.agent, r]));++ assert.equal(by['minimax-code'].state, 'ok');++ assert.equal(by['command-code'].state, 'low', '84% of the week is nearly full');++ assert.equal(by.codex.state, 'limited', 'a full window');++ assert.equal(by.opencode.state, 'login', 'installed, not signed in');++ assert.equal(by.deepseek.state, 'absent');++ assert.equal(rows[0].agent, 'minimax-code', 'the one with the most room comes first');++ assert.equal(by['minimax-code'].machines[0].machine, 'dev_ada_laptop');++ });++++ await check('the router reads the fleet per runner, with the machine that has room', async () => {++ const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([++ { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [++ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET }] },++ { agent: 'minimax-code', installed: true, signed_in: true, windows: [] },++ ] },++ ]) });++ await fleet.refresh(SUBJECT, 'tok');++ const forRouter = await fleet.forRouter(SUBJECT, (agent) => ({ 'minimax-code': 'mm', codex: 'cx' })[agent] || null);++ assert.equal(forRouter.cx.room, false);++ assert.equal(forRouter.mm.room, true);++ assert.equal(forRouter.mm.machine, 'dev_ada_laptop');++ assert.equal(forRouter.claude, undefined, 'Claude has no place in a sub-task fleet');++ });++++ await check('a plan with room on one machine is usable, and the meter shows the fullest reading', async () => {++ const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([++ { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [++ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET }] },++ ] },++ { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: true, fleet: [++ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 10, resets_at: RESET }] },++ ] },++ ]) });++ await fleet.refresh(SUBJECT, 'tok');++ const by = await fleet.byHarness(SUBJECT);++ assert.equal(by.codex.windows[0].pct, 100, 'the fullest reading is the one a person sees');++ assert.equal(by.codex.room, true, 'the desk has room, so a job can run');++ assert.equal(by.codex.machine, 'dev_ada_desk', 'on the machine that has room');++ const row = (await fleet.agents(SUBJECT, null)).find((a) => a.agent === 'codex');++ assert.notEqual(row.state, 'limited', 'room on one machine is not full');++ });++++ await check('a plan that is full on every machine is limited', async () => {++ const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([++ { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [++ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET }] },++ ] },++ { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: true, fleet: [++ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 96, resets_at: RESET }] },++ ] },++ ]) });++ await fleet.refresh(SUBJECT, 'tok');++ const by = await fleet.byHarness(SUBJECT);++ assert.equal(by.codex.room, false);++ assert.equal((await fleet.agents(SUBJECT, null)).find((a) => a.agent === 'codex').state, 'limited');++ });++++ await check('a machine gone from Bot\'s answer is gone here; one that reports no fleet keeps its reading', async () => {++ const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([++ { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [++ { agent: 'codex', installed: true, signed_in: true, windows: [] },++ ] },++ { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: true, fleet: [++ { agent: 'opencode', installed: true, signed_in: false, windows: [] },++ ] },++ ]) });++ await fleet.refresh(SUBJECT, 'tok');++ // The desk's Node is old and reports no fleet; the laptop is unplugged from the answer.++ const next = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([++ { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: true },++ ]) });++ await next.refresh(SUBJECT, 'tok');++ const { machines: left } = await next.latest(SUBJECT);++ assert.deepEqual(left.map((m) => m.id), ['dev_ada_desk'], 'the laptop is gone');++ assert.equal(left[0].agents.length, 1, 'the desk keeps its last reading');++ assert.equal(left[0].agents[0].agent, 'opencode');++ });++++ await check('Bot that does not answer is a problem beside the stored fleet, never an empty one', async () => {++ const down = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => { throw new Error('connect ECONNREFUSED'); } });++ const out = await down.refresh(SUBJECT, 'tok');++ assert.equal(out.ok, false);++ assert.match(out.problem.detail, /did not answer/);++ const { machines: kept } = await down.latest(SUBJECT);++ assert.ok(kept.length >= 1, 'the last reading is still there');++ });++++ await check('an expired session is said, not turned into an empty fleet', async () => {++ const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => ({ ok: false, status: 401, json: async () => ({ detail: 'unauthorized' }) }) });++ await assert.rejects(fleet.refresh(SUBJECT, 'stale'), (e) => e.status === 401);++ });++ } catch (e) {++ console.error(e);++ process.exitCode = 1;++ } finally {++ await testdb.close();++ }++ done();++})();@@@ -39,6 +39,22 @@ const json = (status, body) => ({ ok: status >= 200 && status < 300, status, jsoassert.strictEqual(calls.length, 2, 'the second read comes from the cache');});++ await check('a pull request carries its merge commit and the deploy state of the service it builds', async () => {++ const fetchImpl = async (url) => {++ if (url.endsWith('/pulls/12')) return json(200, { state: 'closed', merged: true, merge_commit_sha: 'a1b2c3d4e5f6', title: 'Ship it', head: { sha: 'abcdef1234', ref: 'codes/x' }, base: { ref: 'main' } });++ if (url.includes('/status')) return json(200, { state: 'success' });++ return json(404, {});++ };++ const seen = [];++ const ship = { async deployOf(what) { seen.push(what); return { state: 'deployed', service: 'codes', release: 'a1b2c3d4e5f6' }; } };++ const gh = createGitHub({ config: {}, fetchImpl, ship, log: { warn() {} } });++ const pr = await gh.pullRequest('https://github.com/OpenVibers/OpenVibe.Codes/pull/12');++ assert.strictEqual(pr.merged, true);++ assert.strictEqual(pr.merge_commit_sha, 'a1b2c3d4e5f6');++ assert.strictEqual(pr.deploy.state, 'deployed');++ assert.deepStrictEqual(seen, [{ repo: 'OpenVibers/OpenVibe.Codes', mergeCommitSha: 'a1b2c3d4e5f6', merged: true }]);++ });++await check('a pull request GitHub does not answer is a problem, never a guessed state', async () => {const gh = createGitHub({ config: {}, fetchImpl: async () => json(404, { message: 'Not Found' }), log: { warn() {} } });const pr = await gh.pullRequest('https://github.com/o/private/pull/3');@@@ -13,7 +13,7 @@ process.env.CODES_RATE_LIMIT_PER_MIN = process.env.CODES_RATE_LIMIT_PER_MIN || '* t.logs(), t.dbDump()*/const http = require('http');--const { startNetwork } = require('./mocks');++const { startNetwork, startBot } = require('./mocks');const captured = [];for (const m of ['log', 'info', 'warn', 'error']) {@@@ -23,10 +23,14 @@ for (const m of ['log', 'info', 'warn', 'error']) {async function boot(opts = {}) {const network = await startNetwork(opts.network || {});++ // OpenVibe.Bot's machines (Codes C9c): a conductor's chosen machine is pinned on Run by the device id Bot names, and++ // the fleet is read from the same answer. A test adds machines with t.bot.addMachine({ id: 'dev_…', node_id, … }).++ const bot = await startBot();const env = {NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.codes', TRUST_PROXY: '1',OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,OV_OAUTH_CLIENT_ID: 'codes', OV_OAUTH_CLIENT_SECRET: 'codes-secret', COOKIE_SECURE: 'false',++ CODES_BOT_INTERNAL_URL: bot.url,...(typeof opts.env === 'function' ? opts.env(network) : (opts.env || {})),};const configLib = require('../../server/config');@@@ -37,7 +41,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.const testdb = await require('./db').testDb();-- const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}) });++ const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}) });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;@@@ -70,11 +74,12 @@ async function boot(opts = {}) {const t = {base, network, config, ctx: built.ctx, get, signIn, dbDump,logs: () => captured.join('\n'),++ bot,async close() {await new Promise((r) => server.close(r));built.ctx.keys.client.stop();await testdb.close();-- await network.close();++ await Promise.all([network.close(), bot.close()]);},};return t;@@@ -390,4 +390,28 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m};}--module.exports = { startNetwork, signJwt };++/**++ * An in-process stand-in for the part of OpenVibe.Bot Codes reads (Codes C9c): GET /api/v1/machines, the person's own++ * machines with their fleet and their Network id. `machines` are seeded with addMachine({ id: 'dev_…', node_id,++ * name, online, fleet, fleet_at }) — the device id is what Run pins a job to, the node id what Network knows it by.++ * Every request is recorded, with its Bearer, so a test can assert who read what.++ */++async function startBot() {++ const st = { machines: [], requests: [], down: false };++ const srv = await listen((req, raw, json) => {++ const url = new URL(req.url, 'http://x');++ st.requests.push({ method: req.method, path: url.pathname, authorization: String(req.headers.authorization || '') });++ if (st.down) return json(503, { code: 'bot.unavailable', detail: 'Bot is restarting' });++ if (url.pathname !== '/api/v1/machines') return json(404, { code: 'route.not_found', detail: 'no such route' });++ if (!String(req.headers.authorization || '').startsWith('Bearer ')) return json(401, { code: 'auth.required', detail: 'sign in to see your machines' });++ return json(200, { machines: st.machines });++ });++ return {++ url: srv.url, state: st, requests: st.requests,++ addMachine(m) { const row = { online: true, fleet: null, fleet_at: null, ...m }; st.machines.push(row); return row; },++ setDown(v) { st.down = v; },++ close: srv.close,++ };++}++++module.exports = { startNetwork, startBot, signJwt };@@@ -80,6 +80,8 @@ async function fakeGitHub() {const db = t.ctx.store.db;const ada = t.network.addUser('ada');t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });++ // The machine, as OpenVibe.Bot answers it: the conductor is pinned to its device id, so Run never moves it.++ t.bot.addMachine({ id: 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2', node_id: NODE, name: 'ada-laptop', online: true });const created = await t.get('/api/v1/conductor', { as: ada, json: { folder: '/home/ada/openvibe', machine: NODE } });assert.strictEqual(created.status, 201, created.text);const conductorId = created.json().task.id;@@@ -177,13 +179,21 @@ async function fakeGitHub() {assert.strictEqual(typeof a.installed, 'boolean');assert.ok(Array.isArray(a.windows));}++ // Codes answers with every machine's stored report: this machine is in Bot's list, but its Node has not++ // reported a fleet yet, so there is nothing stored and nothing is invented.++ assert.ok(Array.isArray(out.machines), 'the stored fleet of every machine');++ assert.strictEqual(out.problem, undefined, 'Bot answered, so there is no problem beside the fleet');++ assert.ok(out.agents.length >= 5, 'and the CLIs of this machine are read directly');});-- await check('codes_ship_status: a pull request\'s CI and merge state, from GitHub', async () => {++ await check('codes_ship_status: a pull request\'s CI, merge and deploy state, from GitHub', async () => {const out = await mcp.call('codes_ship_status', { repo: 'octocat/Hello-World', pr: 7 });assert.strictEqual(out.state, 'open');assert.strictEqual(out.ci, 'Passed');++ assert.strictEqual(out.merged, false);assert.strictEqual(out.title, 'Codes C9b: the conductor');++ assert.strictEqual(out.deploy.state, 'unknown', 'Codes knows no service for this repository');++ assert.strictEqual(out.deploy.service, null);});await check('codes_approve without a conductor task says so, instead of asking nothing', async () => {@@@ -48,7 +48,9 @@ const { boot, check, done } = require('./helpers/boot');// The harness's environment (Codes C3) is not part of the task's own arguments: it carries the run token.const { env, ...submitted } = submit.args;const args = Object.fromEntries(['repo', 'ref', 'prompt', 'permission', 'push', 'agent', 'model'].map((key) => [key, submitted[key]]));-- assert.deepStrictEqual(args, { repo: ask.repo, ref: null, prompt: ask.prompt, permission: 'edit', push: null, agent: null, model: null });++ // A task with no agent named is auto (Codes C9c): the router names the agent it picked, so the task and its++ // first attempt carry it (no fleet has been reported here, so the standard ladder starts at MiniMax).++ assert.deepStrictEqual(args, { repo: ask.repo, ref: null, prompt: ask.prompt, permission: 'edit', push: null, agent: 'minimax-code', model: null });assert.deepStrictEqual(Object.keys(env || {}).sort(), ['OPENAI_API_KEY', 'OPENAI_BASE_URL'], 'the harness gets a run token and OpenVibe.AI\'s address');});nnew file mode 100644@@@ -0,0 +1,82 @@++'use strict';++++/**++ * Notices (Codes C9c): "done" and "needs you" reach the person through Network's notifications, once per task and++ * state, and the two Settings switches (notify_done, notify_needs) are honoured. The Action Center is not a push: it++ * reads the tasks themselves, so this only covers the notification.++ */++const assert = require('assert');++const { check, done } = require('./helpers/boot');++const { createStore } = require('../server/db');++const { createNotices } = require('../server/domain/notices');++const { createUserSettings } = require('../server/domain/user-settings');++const { testDb } = require('./helpers/db');++++const SUBJECT = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2AB';++++(async () => {++ const testdb = await testDb();++ const store = createStore(testdb.db);++ const settings = createUserSettings({ store });++++ try {++ await check('done and needs reach the person through Network, with a way back to the task', async () => {++ const sent = [];++ const notices = createNotices({ store, config: { baseUrl: 'https://openvibe.codes' }, userSettings: settings, notify: { async push(body) { sent.push(body); return { sent: true }; } } });++ const task = { id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2AB', subject: SUBJECT };++ assert.equal((await notices.done(task, 'It added the endpoint and the tests.')).sent, true);++ assert.equal((await notices.needs(task, 'It stopped at its budget.')).sent, true);++ assert.equal(sent.length, 2);++ assert.equal(sent[0].title, 'Your task is done');++ assert.equal(sent[0].url, `https://openvibe.codes/tasks/${task.id}`);++ assert.equal(sent[0].category, 'codes');++ assert.equal(sent[1].title, 'Your task needs you');++ assert.equal(sent[1].priority, 'high');++ assert.match(sent[1].message, /budget/);++ });++++ await check('one notice per task and state: a redelivered event does not tell the person twice', async () => {++ const sent = [];++ const notices = createNotices({ store, config: { baseUrl: 'https://openvibe.codes' }, userSettings: settings, notify: { async push(body) { sent.push(body); return { sent: true }; } } });++ const task = { id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2AC', subject: SUBJECT };++ assert.equal((await notices.done(task)).sent, true);++ const again = await notices.done(task);++ assert.equal(again.sent, false);++ assert.equal(again.reason, 'already');++ assert.equal(sent.length, 1, 'the second call sent nothing');++ });++++ await check('a switch that is off is not an error, and the notice is not retried', async () => {++ await settings.save(SUBJECT, { notify_done: false, notify_needs: false });++ const sent = [];++ const notices = createNotices({ store, config: { baseUrl: 'https://openvibe.codes' }, userSettings: settings, notify: { async push(body) { sent.push(body); return { sent: true }; } } });++ const task = { id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2AD', subject: SUBJECT };++ assert.equal((await notices.done(task, 'finished')).reason, 'switched_off');++ assert.equal((await notices.needs(task, 'needs a person')).reason, 'switched_off');++ assert.equal(sent.length, 0, 'nothing was pushed');++ });++++ await check('a Network that does not answer does not stop the task, and is said', async () => {++ await settings.save(SUBJECT, { notify_done: true, notify_needs: true });++ const notices = createNotices({ store, config: { baseUrl: 'https://openvibe.codes' }, userSettings: settings, log: { warn() {} }, notify: { async push() { throw new Error('network down'); } } });++ const task = { id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2AE', subject: SUBJECT };++ const out = await notices.needs(task, 'it could not start');++ assert.equal(out.sent, false);++ assert.equal(out.reason, 'network_error');++ });++++ await check('a task with no person, or no task, sends nothing', async () => {++ const notices = createNotices({ store, config: {}, userSettings: settings, notify: { async push() { throw new Error('should not be called'); } } });++ assert.equal((await notices.done(null)).reason, 'bad_input');++ assert.equal((await notices.done({ id: 'not-a-task', subject: SUBJECT })).reason, 'bad_input');++ assert.equal((await notices.done({ id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2AF' })).reason, 'no_subject');++ assert.equal((await notices.send({ id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2AF', subject: SUBJECT }, 'nope')).reason, 'bad_input');++ });++ } catch (e) {++ console.error(e);++ process.exitCode = 1;++ } finally {++ await testdb.close();++ }++ done();++})();@@@ -18,7 +18,7 @@ const BUDGETS = {jsRawKB: 340, // 330.9 after C9b: the conductor's message box with its images and its live turn (2.6 KB in js/c4.js); 326.2 after C4b: the home page loads the workspace script for its repository search (10.9 KB of its own); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)jsBrotliKB: 84, // 82.9 after C9b's conductor script; 80.6 after C4b's repository search on the home page; 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)cssFiles: 3, // C4 adds its responsive workspace stylesheet alongside codes.css and run.css.-- cssRawKB: 64, // 62.4 after C9b: the conductor's turns, approvals inbox and composer (public/css/c4.css); 58.4 after C4b: the proof rows, the fold-out panels, the shots, the forms that remember choices and their light/dark styles; 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.++ cssRawKB: 65, // 63.0 after C9c: the fleet's rows and its meters on the Agents page (public/css/c4.css, 1.2 KB); 62.4 after C9b: the conductor's turns, approvals inbox and composer (public/css/c4.css); 58.4 after C4b: the proof rows, the fold-out panels, the shots, the forms that remember choices and their light/dark styles; 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.cssBrotliKB: 14, // 13.0 after C9b's conductor styles; 12.1 after C4b's rows, panels and forms; 9.9 KB after adding the C4 workspace styles.externalFiles: 2, // 0};@@@ -55,6 +55,8 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person','server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person','server/domain/github.js': 'api.github.com only, at fixed paths: the public repository search and a pull request\'s state. The owner/name/number come from a Codes-stored task result and are checked against GitHub\'s id shapes before they are put in a path',++ 'server/domain/fleet.js': 'OpenVibe.Bot (CODES_BOT_INTERNAL_URL, configured) /api/v1/machines with the person\'s own session token: their machines and the fleet their Nodes reported; the path is fixed, never a URL from a person',++ 'server/domain/ship.js': 'the service\'s own GET <publicOrigin>/release.json and api.github.com\'s compare, both at fixed paths: the origin is the contracts\' service manifest, never a URL from a person; the shas are checked hex','server/events-consumer.js': 'OpenVibe.Events subscriptions (EVENTS_URL, configured) at boot; no URL from a person','server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',};nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++++/**++ * Ship tracking (Codes C9c): after CI and the merge, is the service deployed? The signal is the host's own —++ * `GET <publicOrigin>/release.json`, which `ovhost status` reads — compared with the pull request's merge commit. Nothing++ * is guessed from a timer.++ */++const assert = require('assert');++const { check, done } = require('./helpers/boot');++const { createShip, serviceFor } = require('../server/domain/ship');++++const MANIFESTS = [++ { id: 'codes', repository: 'OpenVibers/OpenVibe.Codes', publicOrigin: 'https://openvibe.codes' },++ { id: 'run', repository: 'OpenVibers/OpenVibe.Run', publicOrigin: 'https://run.openvibe.network' },++ { id: 'internal', repository: 'OpenVibers/OpenVibe.Bot' },++];++const MERGE = 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678';++const AFTER = 'ffffffffffffffffffffffffffffffffffffffff';++++const json = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body });++++/** A release.json per origin, and GitHub's compare, stood in for. */++function world({ release = null, originOk = true, compare = null } = {}) {++ const calls = [];++ const fetchImpl = async (url) => {++ calls.push(url);++ if (url === 'https://openvibe.codes/release.json') return originOk ? json(200, { service: 'codes', release }) : json(503, {});++ if (url.includes('/compare/')) return compare == null ? json(502, {}) : json(200, { status: compare });++ return json(404, {});++ };++ return { fetchImpl, calls };++}++++(async () => {++ try {++ await check('a repository is tied to the service its manifest names, and only when it has a public origin', () => {++ assert.equal(serviceFor(MANIFESTS, 'openvibers/openvibe.codes').id, 'codes', 'the match is case-insensitive');++ assert.equal(serviceFor(MANIFESTS, 'OpenVibers/OpenVibe.Bot'), null, 'a manifest without an origin is not deployable');++ assert.equal(serviceFor(MANIFESTS, 'nobody/else'), null);++ assert.equal(serviceFor(MANIFESTS, null), null);++ });++++ await check('the merge commit the service reports is deployed', async () => {++ const { fetchImpl } = world({ release: MERGE });++ const ship = createShip({ config: {}, fetchImpl, manifests: MANIFESTS, log: { warn() {} } });++ const out = await ship.deployOf({ repo: 'OpenVibers/OpenVibe.Codes', mergeCommitSha: MERGE, merged: true });++ assert.equal(out.state, 'deployed');++ assert.equal(out.service, 'codes');++ assert.equal(out.release, MERGE);++ assert.equal(out.origin, 'https://openvibe.codes');++ });++++ await check('a release that contains the merge commit (GitHub says ahead or identical) is deployed', async () => {++ const { fetchImpl } = world({ release: AFTER, compare: 'ahead' });++ const ship = createShip({ config: {}, fetchImpl, manifests: MANIFESTS, log: { warn() {} } });++ const out = await ship.deployOf({ repo: 'OpenVibers/OpenVibe.Codes', mergeCommitSha: MERGE, merged: true });++ assert.equal(out.state, 'deployed');++ });++++ await check('merged but behind the service\'s release is not live yet', async () => {++ const { fetchImpl } = world({ release: AFTER, compare: 'behind' });++ const ship = createShip({ config: {}, fetchImpl, manifests: MANIFESTS, log: { warn() {} } });++ const out = await ship.deployOf({ repo: 'OpenVibers/OpenVibe.Codes', mergeCommitSha: MERGE, merged: true });++ assert.equal(out.state, 'pending');++ assert.match(out.detail, /not deployed yet/);++ });++++ await check('an unmerged pull request waits, and a service that does not answer is not read', async () => {++ const { fetchImpl } = world({ release: MERGE });++ const ship = createShip({ config: {}, fetchImpl, manifests: MANIFESTS, log: { warn() {} } });++ assert.equal((await ship.deployOf({ repo: 'OpenVibers/OpenVibe.Codes', mergeCommitSha: null, merged: false })).state, 'waiting');++ const down = createShip({ config: {}, fetchImpl: world({ originOk: false }).fetchImpl, manifests: MANIFESTS, log: { warn() {} } });++ const out = await down.deployOf({ repo: 'OpenVibers/OpenVibe.Codes', mergeCommitSha: MERGE, merged: true });++ assert.equal(out.state, 'unknown');++ assert.match(out.detail, /release\.json/);++ });++++ await check('a repository Codes knows no service for has no deploy state, not a wrong one', async () => {++ const { fetchImpl, calls } = world({ release: MERGE });++ const ship = createShip({ config: {}, fetchImpl, manifests: MANIFESTS, log: { warn() {} } });++ const out = await ship.deployOf({ repo: 'octocat/Hello-World', mergeCommitSha: MERGE, merged: true });++ assert.equal(out.state, 'unknown');++ assert.equal(out.service, null);++ assert.equal(calls.length, 0, 'no origin was asked');++ });++ } catch (e) {++ console.error(e);++ process.exitCode = 1;++ }++ done();++})();
@@ -20,6 +20,8 @@
@@ -20,6 +20,8 @@
"openvibe-codes on a person's machine (bin/openvibe-codes.js, harness/): agents, route, run, runs, show, resume; installs from the release tag",
"openvibe-codes on a person's machine (bin/openvibe-codes.js, harness/): agents, route, run, runs, show, resume; installs from the release tag",
"openvibe-codes task (bin/task.js): a coding task on openvibe.codes as the person — start from a brief (with --name, --tier, --permission review|edit|full), list running/needs/done, follow the event stream, continue, stop",
"openvibe-codes task (bin/task.js): a coding task on openvibe.codes as the person — start from a brief (with --name, --tier, --permission review|edit|full), list running/needs/done, follow the event stream, continue, stop",
"The conductor (C9b): a task whose single attempt is a long-lived Claude Code session on a machine the person chooses, with their own plan login; each message from the web or the phone is the next turn of that session, and its work is handed to the other agents through openvibe-codes mcp (codes_task_start, list, show, continue, stop, fleet, ship status, approve), with a risky call approved or denied from the phone",
"The conductor (C9b): a task whose single attempt is a long-lived Claude Code session on a machine the person chooses, with their own plan login; each message from the web or the phone is the next turn of that session, and its work is handed to the other agents through openvibe-codes mcp (codes_task_start, list, show, continue, stop, fleet, ship status, approve), with a risky call approved or denied from the phone",
+ "The fleet (C9c): what each agent on the person's machines has left — installed, signed in, and every limit window with when it frees — reported by each machine's Node with its heartbeat and kept here (labels, numbers and times only); the router skips an agent whose window is full or that is not signed in, prefers a flat-price plan with room, and leaves out a pay-per-token agent whose balance is nearly gone",
+ "A task with no agent named is auto: the router reads the fleet and picks, and a task no agent may take says why and needs a person; a finished or unfinished task tells the person once (Settings: notify_done, notify_needs), and a pull request's CI, merge and deploy are on the task page",
"Adapters for Claude Code, Codex, OpenCode, Command Code and Aider (their CLIs) and for the DeepSeek API and any OpenAI-compatible server (the harness's own agent loop with list/read/grep/write/edit/run tools, confined to the working directory)",
"Adapters for Claude Code, Codex, OpenCode, Command Code and Aider (their CLIs) and for the DeepSeek API and any OpenAI-compatible server (the harness's own agent loop with list/read/grep/write/edit/run tools, confined to the working directory)",
"One event stream for every agent, in Claude Code's stream-json shape, ending with exactly one result; keys and tokens redacted",
"One event stream for every agent, in Claude Code's stream-json shape, ending with exactly one result; keys and tokens redacted",
"Hand-offs: a failed, rate-limited, crashed or silent attempt hands the task to the next routed agent with a note (task, tools, files changed, last message, git status)",
"Hand-offs: a failed, rate-limited, crashed or silent attempt hands the task to the next routed agent with a note (task, tools, files changed, last message, git status)",
@@ -26,6 +26,7 @@
@@ -26,6 +26,7 @@
const os = require('os');
const os = require('os');
const { auth, call } = require('./task');
const { auth, call } = require('./task');
const { probe: probeLogins } = require('../engine/logins');
const { probe: probeLogins } = require('../engine/logins');
+const { createShip } = require('../server/domain/ship');
const APPROVAL_TIMEOUT_MS = Math.max(10_000, Number(process.env.APPROVAL_TIMEOUT_MS) || 15 * 60_000);
const APPROVAL_TIMEOUT_MS = Math.max(10_000, Number(process.env.APPROVAL_TIMEOUT_MS) || 15 * 60_000);
const POLL_MS = 2500;
const POLL_MS = 2500;
@@ -96,12 +97,12 @@ const TOOLS = [
@@ -96,12 +97,12 @@ const TOOLS = [
},
},
{
{
name: 'codes_fleet',
name: 'codes_fleet',
- description: 'The coding CLIs on this machine: installed, signed in, and their limit windows (read from the CLIs\' own files, read-only).',
+ description: 'The coding agents on the owner\'s machines: installed, signed in, and every limit window with when it frees. Each Node reports its fleet with its heartbeat; this machine is read directly from the CLIs\' own files. Read-only.',
inputSchema: { type: 'object', properties: {}, additionalProperties: true },
inputSchema: { type: 'object', properties: {}, additionalProperties: true },
},
},
{
{
name: 'codes_ship_status',
name: 'codes_ship_status',
- description: 'A GitHub pull request\'s state: CI, merge state, title and branch. Read-only.',
+ description: 'A GitHub pull request\'s state: CI, merge, and whether the service it builds is deployed (the service\'s own /release.json, as `ovhost status` reads it). Read-only.',
inputSchema: { type: 'object', properties: { repo: { type: 'string' }, pr: { type: 'number', description: 'The pull request number' } }, required: ['repo', 'pr'], additionalProperties: true },
inputSchema: { type: 'object', properties: { repo: { type: 'string' }, pr: { type: 'number', description: 'The pull request number' } }, required: ['repo', 'pr'], additionalProperties: true },
},
},
{
{
@@ -202,15 +203,49 @@ async function stopTask(args) {
@@ -202,15 +203,49 @@ async function stopTask(args) {
return text({ id: t.id, state: t.state, outcome: t.outcome ? t.outcome.state : null });
return text({ id: t.id, state: t.state, outcome: t.outcome ? t.outcome.state : null });
}
}
-async function fleet() {
+/** This machine's own reading: the CLIs' files, the same answer the Node sends with its heartbeat. */
+function here() {
const rows = probeLogins({ home: os.homedir() });
const rows = probeLogins({ home: os.homedir() });
- return text({
+ return {
machine: os.hostname(),
machine: os.hostname(),
agents: rows.map((r) => ({
agents: rows.map((r) => ({
agent: r.id, installed: !!r.installed, signed_in: !!r.signed_in,
agent: r.id, installed: !!r.installed, signed_in: !!r.signed_in,
version: r.version || null,
version: r.version || null,
windows: (r.windows || []).map((w) => ({ name: w.name, used: w.used == null ? null : w.used, limit: w.limit == null ? null : w.limit, resets_at: w.resets_at || null })),
windows: (r.windows || []).map((w) => ({ name: w.name, used: w.used == null ? null : w.used, limit: w.limit == null ? null : w.limit, resets_at: w.resets_at || null })),
})),
})),
+ };
+}
+
+/**
+ * The fleet: every machine's latest report from Codes (which Bot answers from the Nodes' heartbeats), and this
+ * machine read directly. Without a token, or when Codes cannot answer, this machine's reading is still the answer,
+ * and the reason is said rather than hidden.
+ */
+async function fleet() {
+ let local = null;
+ let localProblem = null;
+ try { local = here(); } catch (e) { localProblem = String(e && e.message).slice(0, 200); }
+ let stored = null;
+ let problem = localProblem ? { detail: `this machine could not be read: ${localProblem}` } : null;
+ try {
+ const c = auth();
+ const answer = await call(c, 'GET', '/api/v1/fleet');
+ stored = {
+ machines: (answer.machines || []).map((m) => ({
+ machine: m.id, node_id: m.node_id || null, name: m.name || null, online: !!m.online, reported_at: m.reported_at || null,
+ agents: (m.agents || []).map((a) => ({ agent: a.agent, installed: !!a.installed, signed_in: !!a.signed_in, windows: a.windows || [] })),
+ })),
+ agents: answer.agents || [],
+ at: answer.at || null,
+ };
+ if (answer.problem && !problem) problem = answer.problem;
+ } catch (e) {
+ problem = problem || { detail: String(e && e.message).slice(0, 300) };
+ }
+ return text({
+ ...(local || { machine: os.hostname(), agents: [] }),
+ ...(stored ? { machines: stored.machines, all_agents: stored.agents, at: stored.at } : {}),
+ ...(problem ? { problem } : {}),
});
});
}
}
@@ -236,11 +271,18 @@ async function shipStatus(args) {
@@ -236,11 +271,18 @@ async function shipStatus(args) {
const st = await get(`/repos/${gh.owner}/${gh.name}/commits/${sha}/status`);
const st = await get(`/repos/${gh.owner}/${gh.name}/commits/${sha}/status`);
if (st.ok && st.doc) ci = ({ success: 'Passed', failure: 'Failed', error: 'Errored', pending: 'Running' })[st.doc.state] || null;
if (st.ok && st.doc) ci = ({ success: 'Passed', failure: 'Failed', error: 'Errored', pending: 'Running' })[st.doc.state] || null;
}
}
+ // The deploy is the service's own /release.json — the same signal `ovhost status` reads.
+ const ship = createShip({ config: { github: { token } }, fetchImpl: fetch });
+ const deploy = await ship.deployOf({ repo: `${gh.owner}/${gh.name}`, mergeCommitSha: d.merge_commit_sha || null, merged: !!d.merged });
return text({
return text({
repo: `${gh.owner}/${gh.name}`, pr, title: d.title || null,
repo: `${gh.owner}/${gh.name}`, pr, title: d.title || null,
state: d.merged ? 'merged' : d.state === 'closed' ? 'closed' : 'open',
state: d.merged ? 'merged' : d.state === 'closed' ? 'closed' : 'open',
+ merged: !!d.merged, merge_commit_sha: d.merge_commit_sha || null,
draft: !!d.draft, mergeable: d.mergeable == null ? null : !!d.mergeable, mergeable_state: d.mergeable_state || null,
draft: !!d.draft, mergeable: d.mergeable == null ? null : !!d.mergeable, mergeable_state: d.mergeable_state || null,
- ci, branch: d.head && d.head.ref ? d.head.ref : null, base: d.base && d.base.ref ? d.base.ref : null,
+ ci, deploy: {
+ state: deploy.state, service: deploy.service, release: deploy.release, booted_at: deploy.booted_at, detail: deploy.detail || null,
+ },
+ branch: d.head && d.head.ref ? d.head.ref : null, base: d.base && d.base.ref ? d.base.ref : null,
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+
+/**
+ * What an agent's account has left, read the way the portal reads it (Codes C9c).
+ *
+ * A plan window (the fleet) says how much of a paid plan is used. A pay-per-token agent has no window: it has a
+ * balance, and DeepSeek says what is left of it at `GET https://api.deepseek.com/user/balance` — the same call the
+ * portal's account card makes, with the person's own key in `~/.config/deepseek/api_key`, and the same reading of the
+ * answer: `balance_infos[0].total_balance`.
+ *
+ * The key never leaves the machine that holds it: it goes to DeepSeek and nowhere else, and only the number comes
+ * back. A key that is not there, a call that does not answer, or an answer that says nothing about a balance, is
+ * "unknown" (null) — never a number of zero, which would read as "spent".
+ *
+ * readBalances({ home, fetchImpl, endpoint }) → { deepseek: { usd, at } } (a harness with no key is absent)
+ */
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+/** The call the portal makes, and the file the key is in. */
+const DEEPSEEK_ENDPOINT = 'https://api.deepseek.com/user/balance';
+const DEEPSEEK_KEY_FILE = path.join('.config', 'deepseek', 'api_key');
+
+/** The harness each balance belongs to: the pay-per-token agents Codes runs. */
+const ACCOUNTS = { deepseek: { endpoint: DEEPSEEK_ENDPOINT, keyFile: DEEPSEEK_KEY_FILE } };
+
+/**
+ * The key of an account, read from the file the CLI keeps it in. Nothing else: no key file, no key.
+ *
+ * @returns {string|null} the trimmed key, or null when there is none
+ */
+function keyOf(account, { home = os.homedir() } = {}) {
+ try {
+ const key = fs.readFileSync(path.join(home, account.keyFile), 'utf8').trim();
+ return key || null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * One account's balance, as the provider says it.
+ *
+ * @param {string} name the account's name (deepseek)
+ * @param {object} opts
+ * @param {string} [opts.home] the home the key file is under
+ * @param {Function} [opts.fetchImpl] the fetch to use
+ * @param {string} [opts.endpoint] the endpoint to call
+ * @returns {Promise<{usd: number, at: string}|null>} the balance, or null when it is unknown
+ */
+async function readBalance(name, { home = os.homedir(), fetchImpl = globalThis.fetch, endpoint = null } = {}) {
+ const account = ACCOUNTS[name];
+ if (!account) return null;
+ const key = keyOf(account, { home });
+ if (!key) return null;
+ const url = endpoint || account.endpoint;
+ let doc;
+ try {
+ const res = await fetchImpl(url, {
+ headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
+ signal: AbortSignal.timeout(8000),
+ });
+ if (!res.ok) return null;
+ doc = await res.json().catch(() => null);
+ } catch {
+ return null;
+ }
+ const infos = doc && Array.isArray(doc.balance_infos) ? doc.balance_infos : [];
+ const first = infos[0];
+ if (!first || typeof first !== 'object') return null;
+ const usd = Number(first.total_balance);
+ if (!Number.isFinite(usd)) return null;
+ return { usd, at: new Date().toISOString() };
+}
+
+/**
+ * Every account's balance this module knows: { [harness]: { usd, at } }. A harness with no key, or one that did not
+ * answer, is absent — an unknown balance, which is never a reason to skip an agent.
+ *
+ * @param {object} opts
+ * @returns {Promise<object>} the balances
+ */
+async function readBalances({ home = os.homedir(), fetchImpl = globalThis.fetch, endpoint = null, names = Object.keys(ACCOUNTS) } = {}) {
+ const out = {};
+ for (const name of names) {
+ const one = await readBalance(name, { home, fetchImpl, endpoint });
+ if (one) out[name] = one;
+ }
+ return out;
+}
+
+module.exports = { readBalance, readBalances, keyOf, ACCOUNTS, DEEPSEEK_ENDPOINT, DEEPSEEK_KEY_FILE };
@@ -38,6 +38,77 @@ const LADDER = {
@@ -38,6 +38,77 @@ const LADDER = {
/** The tiers a brief can fall into. */
/** The tiers a brief can fall into. */
const TIERS = ['basic', 'research', 'standard', 'hard', 'design'];
const TIERS = ['basic', 'research', 'standard', 'hard', 'design'];
+/**
+ * How full a plan's window may be and still take a new job: 95%, not 100%. A task started with 4% of a week left would
+ * run out of it mid-way, and the portal's own "low" state treats a near-full window as spent (today Command Code is at
+ * 96% of its week and must not be chosen).
+ */
+const FULL_PCT = 95;
+
+/**
+ * A limit window is full when it has no room left, or when its reset is still ahead and it is at FULL_PCT or more.
+ * `used`/`limit` and `pct` are the two shapes providers report; `resets_at` may be an ISO string or epoch ms.
+ *
+ * @param {object} w a window: { name, used, limit, pct, resets_at }
+ * @param {number} [now] the clock
+ * @returns {boolean} may a new job be started in this window?
+ */
+function windowFull(w, now = Date.now()) {
+ if (!w || typeof w !== 'object') return false;
+ const resets = w.resets_at == null || w.resets_at === '' ? null : Date.parse(String(w.resets_at));
+ if (Number.isFinite(resets) && resets > 0 && resets <= now) return false; // it has freed since it was read
+ let pct = Number(w.pct);
+ if (!Number.isFinite(pct)) {
+ const used = Number(w.used); const limit = Number(w.limit);
+ pct = Number.isFinite(used) && Number.isFinite(limit) && limit > 0 ? (used / limit) * 100 : NaN;
+ }
+ return Number.isFinite(pct) && pct >= FULL_PCT;
+}
+
+/**
+ * The lowest balance a pay-per-token agent may take a job on: $1. DeepSeek reads its balance the way the portal reads
+ * it (engine/balance.js), and a balance under a dollar would not pay for the job: the agent would stop mid-way and the
+ * task would need a person.
+ */
+const MIN_BALANCE_USD = 1;
+
+/**
+ * Whether a runner's balance is too low to take a job. A balance nobody read is unknown, not empty: the agent stays
+ * in the ladder (the ladder worked for years without one).
+ *
+ * @param {object} [fleet] the fleet per runner: { [runner]: { signed_in, windows, balance_usd } }
+ * @param {string} runner the runner's id
+ * @param {number} [min] the lowest balance that may take a job
+ * @returns {boolean} is the balance spent?
+ */
+function balanceLow(fleet, runner, min = MIN_BALANCE_USD) {
+ const f = fleet && fleet[runner];
+ if (!f) return false;
+ if (f.balance_usd == null || f.balance_usd === '') return false; // unknown, not empty
+ const usd = Number(f.balance_usd);
+ if (!Number.isFinite(usd)) return false;
+ return usd < min;
+}
+
+/**
+ * Whether a runner has room, from the fleet the person's machines reported. An agent the fleet does not name is
+ * unknown, not full: the ladder keeps working before any machine has reported (and for the API-key agents, which have no
+ * plan window at all).
+ *
+ * @param {object} [fleet] the fleet per runner: { [runner]: { signed_in, windows, balance_usd } }
+ * @param {string} runner the runner's id
+ * @param {number} [now] the clock
+ * @returns {boolean} may this runner take a job now
+ */
+function roomFor(fleet, runner, now = Date.now()) {
+ const f = fleet && fleet[runner];
+ if (!f) return true;
+ if (f.signed_in === false) return false;
+ if (balanceLow(fleet, runner)) return false;
+ const windows = Array.isArray(f.windows) ? f.windows : [];
+ return !windows.some((w) => windowFull(w, now));
+}
+
// The brief a launch prompt points at: "read /…/agents/jobs/x.brief.md and follow it" is a pointer, not a task, so
// The brief a launch prompt points at: "read /…/agents/jobs/x.brief.md and follow it" is a pointer, not a task, so
// the job is judged by the brief it names (2026-10-29: a pin job was routed as "a short question" to a free model
// the job is judged by the brief it names (2026-10-29: a pin job was routed as "a short question" to a free model
// because its prompt was one sentence long). The job's own brief counts too (jobs/<name>.brief.md, jobs/<name>.task.md).
// because its prompt was one sentence long). The job's own brief counts too (jobs/<name>.brief.md, jobs/<name>.task.md).
@@ -170,13 +241,14 @@ function connected(runner, connections) {
@@ -170,13 +241,14 @@ function connected(runner, connections) {
* @param {string} [ctx.name] the job's own name
* @param {string} [ctx.name] the job's own name
* @param {object} [ctx.ladder] the ladder to use (the portal's by default)
* @param {object} [ctx.ladder] the ladder to use (the portal's by default)
* @param {object} [ctx.connections] the person's connections, per runner (Codes C3)
* @param {object} [ctx.connections] the person's connections, per runner (Codes C3)
+ * @param {object} [ctx.fleet] the fleet per runner: { [runner]: { signed_in, windows } } (Codes C9c)
* @returns {{runner: string, model: string|null, effort: string|null, tier: string, brief: string|null, why: string}|null}
* @returns {{runner: string, model: string|null, effort: string|null, tier: string, brief: string|null, why: string}|null}
*/
*/
-function pick(prompt, { cfg, health, counts, models, runs, JOBS, name, ladder, connections } = {}) {
+function pick(prompt, { cfg, health, counts, models, runs, JOBS, name, ladder, connections, fleet } = {}) {
const c = classify(prompt, { JOBS, name });
const c = classify(prompt, { JOBS, name });
const rec = track(runs);
const rec = track(runs);
const reliable = (r) => { const t = rec[r]; return !t || t.done + t.failed < 4 || t.done / (t.done + t.failed) >= 0.4; };
const reliable = (r) => { const t = rec[r]; return !t || t.done + t.failed < 4 || t.done / (t.done + t.failed) >= 0.4; };
- const usable = (r) => cfg.subagents[r] && cfg.subagents[r].enabled && (counts[r] || 0) < cfg.subagents[r].max && health[r] && health[r].state === 'ok' && reliable(r) && connected(r, connections);
+ const usable = (r) => cfg.subagents[r] && cfg.subagents[r].enabled && (counts[r] || 0) < cfg.subagents[r].max && health[r] && health[r].state === 'ok' && reliable(r) && connected(r, connections) && roomFor(fleet, r);
for (const [runner, model, wanted] of (ladder || LADDER)[c.tier]) {
for (const [runner, model, wanted] of (ladder || LADDER)[c.tier]) {
if (!usable(runner)) continue;
if (!usable(runner)) continue;
// A model the runner no longer lists is skipped for that runner's default (lists refresh from each provider).
// A model the runner no longer lists is skipped for that runner's default (lists refresh from each provider).
@@ -188,4 +260,4 @@ function pick(prompt, { cfg, health, counts, models, runs, JOBS, name, ladder, c
@@ -188,4 +260,4 @@ function pick(prompt, { cfg, health, counts, models, runs, JOBS, name, ladder, c
return null;
return null;
}
}
-module.exports = { LADDER, TIERS, classify, classifyText, pick, track, briefFor, forcedTier, fitEffort, stepUp, EFFORTS, connected };
+module.exports = { LADDER, TIERS, FULL_PCT, MIN_BALANCE_USD, classify, classifyText, pick, track, briefFor, forcedTier, fitEffort, stepUp, EFFORTS, connected, windowFull, balanceLow, roomFor };
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+-- phase: expand
+-- Codes C9c: the fleet, and the notices a task sends once.
+--
+-- codes_machine_fleet is the latest state each of a person's machines reported with its heartbeat: per agent, whether
+-- it is installed and signed in, and the limit windows it can read. It holds no key, token or account name (the Node
+-- reports numbers and labels only), and a machine that reports again replaces its own rows.
+CREATE TABLE IF NOT EXISTS codes_machine_fleet (
+ subject text COLLATE "C" NOT NULL, -- usr_… the machines' owner
+ machine text COLLATE "C" NOT NULL, -- the device id Run places a job on (dev_…)
+ node_id text COLLATE "C", -- the Network principal (nod_…), when the machine has one
+ name text COLLATE "C", -- the machine's name, as its owner gave it
+ agent text COLLATE "C" NOT NULL, -- a harness id: codex, opencode, command-code, minimax-code, claude-code
+ installed boolean NOT NULL DEFAULT false,
+ signed_in boolean NOT NULL DEFAULT false,
+ windows jsonb NOT NULL DEFAULT '[]'::jsonb, -- [{name, used, limit, pct, resets_at}], numbers and labels only
+ online boolean NOT NULL DEFAULT false,
+ reported_at timestamptz, -- when the machine last reported this fleet
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (subject, machine, agent)
+);
+CREATE INDEX IF NOT EXISTS codes_machine_fleet_subject ON codes_machine_fleet (subject, agent);
+
+-- codes_notices: one notification per task and state ('done', 'needs'). The unique row is what makes a notice once:
+-- a task's state can be recorded again (a redelivery, a fallback read), and the person is not told twice.
+CREATE TABLE IF NOT EXISTS codes_notices (
+ task_id text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL, -- done | needs
+ sent_at timestamptz NOT NULL DEFAULT now(),
+ result text COLLATE "C",
+ PRIMARY KEY (task_id, kind)
+);
+
+-- A conductor's chosen machine, pinned on Run by its OpenVibe.Bot device id (`dev_…`, the id codes_machine_fleet stores
+-- as `machine`): every turn of the session runs there, where its folder and its memory are (Run's job `node`, contracts
+-- 0.250.0). NULL: not a conductor, or a conductor created without a machine — Run places the job where it fits, as before.
+ALTER TABLE run_tasks ADD COLUMN IF NOT EXISTS pin text COLLATE "C";
@@ -194,6 +194,30 @@
@@ -194,6 +194,30 @@
@media(max-width:760px){
@media(max-width:760px){
body.ov-workspace{padding-bottom:var(--ovnav-appbar-h,calc(58px + env(safe-area-inset-bottom,0px)))}
body.ov-workspace{padding-bottom:var(--ovnav-appbar-h,calc(58px + env(safe-area-inset-bottom,0px)))}
}
}
+
+
+/* C9c: the fleet. A desktop row reads across: the agent and its machine, its meters, its state. A phone row is the
+ agent and its state, then the meters under it. */
+.c4-fleet .c4-fleet-row{grid-template-columns:12px minmax(0,1fr) auto;padding:12px 0;align-items:center;column-gap:10px}
+.c4-fleet-row .c4-row-dot{grid-row:1/3;align-self:center}
+.c4-fleet-row .c4-row-label{grid-column:2;grid-row:1}
+.c4-fleet-row .c4-row-note{grid-column:2;grid-row:2}
+.c4-fleet-row > .c4-pill{grid-column:3;grid-row:1/3;align-self:center}
+.c4-fleet-windows{grid-column:2/-1;grid-row:3;display:grid;gap:6px;margin-top:6px;min-width:0}
+.c4-meter{display:grid;grid-template-columns:46px minmax(0,1fr);align-items:center;gap:3px 10px;font-size:12px;min-width:0}
+.c4-meter-name{color:var(--c4-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.c4-meter-track{display:block;height:6px;border-radius:99px;background:var(--c4-quiet-bg);overflow:hidden}
+.c4-meter-fill{display:block;height:100%;border-radius:99px;background:var(--c4-dot-ok)}
+.c4-meter-fill.is-full{background:var(--c4-dot-bad)}
+.c4-meter-note{grid-column:2;color:var(--c4-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
+.c4-meter-note b{color:var(--c4-ink);font-weight:650}
+@media(min-width:761px){
+ .c4-fleet .c4-fleet-row{grid-template-columns:12px minmax(180px,260px) minmax(0,1fr) 128px;column-gap:18px}
+ .c4-fleet-windows{grid-column:3;grid-row:1/3;margin:0}
+ .c4-meter{grid-template-columns:52px minmax(120px,1fr) minmax(0,auto)}
+ .c4-meter-note{grid-column:3}
+ .c4-fleet-row > .c4-pill{grid-column:4;justify-self:end}
+}
/* The narrowest phones: one control per row, and a heading over its state, so no label is cut or broken
/* The narrowest phones: one control per row, and a heading over its state, so no label is cut or broken
(a 320 px screen has ~290 px of text; "Opus 5.5 (default)" and "Conductor" both fit whole). */
(a 320 px screen has ~290 px of text; "Opus 5.5 (default)" and "Conductor" both fit whole). */
@media(max-width:380px){
@media(max-width:380px){
@@ -204,4 +228,4 @@
@@ -204,4 +228,4 @@
/* The season's leaves fall from the Frame's mark over the page (openvibe-shared/ov-mark.js, .ovm-fall, a fixed layer at
/* The season's leaves fall from the Frame's mark over the page (openvibe-shared/ov-mark.js, .ovm-fall, a fixed layer at
z-index 10010, 120-280 px down). The page's own title stays above them: a once-a-day decoration may pass behind a
z-index 10010, 120-280 px down). The page's own title stays above them: a once-a-day decoration may pass behind a
heading, never across it. Reported to Shared so every site's heading is clear of the fall. */
heading, never across it. Reported to Shared so every site's heading is clear of the fall. */
-.c4 h1{position:relative;z-index:10011}
+.c4 h1{position:relative;z-index:10011}
\ No newline at end of file
\ No newline at end of file
@@ -28,12 +28,15 @@ const TASK_ROWS_MAX = 20000;
@@ -28,12 +28,15 @@ const TASK_ROWS_MAX = 20000;
const TABLES = [
const TABLES = [
{
{
table: 'run_tasks', subject: 'subject', file: 'run-tasks.json', order: 'created_at',
table: 'run_tasks', subject: 'subject', file: 'run-tasks.json', order: 'created_at',
- columns: ['id', 'project_id', 'job_id', 'repo', 'ref', 'prompt', 'agent', 'push', 'pull_request', 'state', 'result', 'error', 'started_at', 'finished_at', 'created_at', 'updated_at'],
+ columns: ['id', 'project_id', 'job_id', 'repo', 'ref', 'prompt', 'agent', 'push', 'pull_request', 'state', 'result', 'error', 'started_at', 'finished_at', 'created_at', 'updated_at', 'machine', 'pin'],
// Erased by extraErase below, after the rows that point at these task ids (the SDK deletes the table's rows
// Erased by extraErase below, after the rows that point at these task ids (the SDK deletes the table's rows
// before calling it, so a default delete here would lose the ids first).
// before calling it, so a default delete here would lose the ids first).
erase: { keep: 'erased with its task-keyed rows by the service extraErase' },
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' },
+ // 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'] },
// The token itself is never stored; its jti is an internal handle and stays out of the export.
// The token itself is never stored; its jti is an internal handle and stays out of the export.
{ table: 'codes_run_tokens', subject: 'subject', file: 'run-tokens.json', order: 'created_at', columns: ['id', 'task_id', 'attempt_id', 'provider', 'expires_at', 'created_at'] },
{ table: 'codes_run_tokens', subject: 'subject', file: 'run-tokens.json', order: 'created_at', columns: ['id', 'task_id', 'attempt_id', 'provider', 'expires_at', 'created_at'] },
{ table: 'codes_outbox', subject: 'subject', file: 'events-sent.json', order: 'created_at', columns: ['event_id', 'topic', 'created_at', 'published_at'] },
{ table: 'codes_outbox', subject: 'subject', file: 'events-sent.json', order: 'created_at', columns: ['event_id', 'topic', 'created_at', 'published_at'] },
@@ -54,6 +57,8 @@ const TASK_KEYED = [
@@ -54,6 +57,8 @@ const TASK_KEYED = [
// The approvals the person decided, and the ones still waiting when their account is erased.
// The approvals the person decided, and the ones still waiting when their account is erased.
{ table: 'codes_approvals', file: 'approvals.json', columns: 'l.id, l.task_id, l.tool, l.summary, l.decision, l.message, l.created_at, l.decided_at', order: 'l.created_at ASC', kind: 'approvals', max: TASK_ROWS_MAX },
{ table: 'codes_approvals', file: 'approvals.json', columns: 'l.id, l.task_id, l.tool, l.summary, l.decision, l.message, l.created_at, l.decided_at', order: 'l.created_at ASC', kind: 'approvals', max: TASK_ROWS_MAX },
{ table: 'codes_events_inbox', file: null, kind: 'events_received' },
{ table: 'codes_events_inbox', file: null, kind: 'events_received' },
+ // The one notice sent per task and state (Codes C9c): no content of its own, erased with the task, not exported.
+ { table: 'codes_notices', file: null, kind: 'notices' },
];
];
async function exists(q, table) {
async function exists(q, table) {
@@ -51,6 +51,8 @@ const { createCodesReadiness } = require('./observability');
@@ -51,6 +51,8 @@ const { createCodesReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
const { createActorLimits } = require('./http/actor-limits');
const { createForgejoClient } = require('./domain/forgejo');
const { createForgejoClient } = require('./domain/forgejo');
const { createMachines } = require('./domain/machines');
const { createMachines } = require('./domain/machines');
+const { createFleet } = require('./domain/fleet');
+const { createNotices } = require('./domain/notices');
const { createUserSettings } = require('./domain/user-settings');
const { createUserSettings } = require('./domain/user-settings');
const { createGitHub } = require('./domain/github');
const { createGitHub } = require('./domain/github');
const { createUsage } = require('./domain/usage');
const { createUsage } = require('./domain/usage');
@@ -85,18 +87,22 @@ async function createApp(opts = {}) {
@@ -85,18 +87,22 @@ async function createApp(opts = {}) {
const runTokens = createRunTokens({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
const runTokens = createRunTokens({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
// The task's attempts (stage C2): every Run job is one attempt, and the engine decides what happens next.
// The task's attempts (stage C2): every Run job is one attempt, and the engine decides what happens next.
const attempts = createAttempts({ store, config, log, harnesses });
const attempts = createAttempts({ store, config, log, harnesses });
+ // The fleet (C9c): each machine's agents and limit windows, reported with its heartbeat and read from Bot.
+ const fleet = createFleet({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log, now: store.now, balance: opts.balance || null });
+ // A person's choices (their notification switches) and the notices a task sends once (C9c).
+ const userSettings = createUserSettings({ store });
+ 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 benchPublisher = createBenchPublisher({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
- const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
- const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks, connections, runTokens, attempts, benchPublisher };
+ 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 };
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 });
- ctx.userSettings = createUserSettings({ store });
ctx.github = createGitHub({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.github = createGitHub({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.usage = createUsage({ store });
ctx.usage = createUsage({ store });
// The conductor (Codes C9b) and its approvals inbox. The conductor starts Run jobs and reads their events through
// The conductor (Codes C9b) and its approvals inbox. The conductor starts Run jobs and reads their events through
// tasks, so the two are wired to each other here, not in a constructor (which would be a cycle).
// tasks, so the two are wired to each other here, not in a constructor (which would be a cycle).
- ctx.notify = opts.notify || createNotify({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.approvals = createApprovals({ store, config, log, notify: ctx.notify, userSettings: ctx.userSettings });
ctx.approvals = createApprovals({ store, config, log, notify: ctx.notify, userSettings: ctx.userSettings });
ctx.conductor = createConductor({
ctx.conductor = createConductor({
store, config, log, run, attempts, tasks, sessions, approvals: ctx.approvals, notify: ctx.notify,
store, config, log, run, attempts, tasks, sessions, approvals: ctx.approvals, notify: ctx.notify,
@@ -202,7 +208,7 @@ async function createApp(opts = {}) {
@@ -202,7 +208,7 @@ async function createApp(opts = {}) {
// Run on your machine (plan T16): coding tasks on the person's own machine, through OpenVibe Run, from the /run
// Run on your machine (plan T16): coding tasks on the person's own machine, through OpenVibe Run, from the /run
// pages below or, for a first-party service acting for the person, from /api/v1/tasks (codes.task.*).
// pages below or, for a first-party service acting for the person, from /api/v1/tasks (codes.task.*).
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
- app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, actorLimits: ctx.actorLimits, log }));
+ app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));
// A person's connections: their own provider keys (stored in OpenVibe.AI, never here) and their machines' plans.
// A person's connections: their own provider keys (stored in OpenVibe.AI, never here) and their machines' plans.
app.use('/api/v1', createConnectionsApi({ config, store, connections, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createConnectionsApi({ config, store, connections, actorLimits: ctx.actorLimits, log }));
@@ -210,7 +216,7 @@ async function createApp(opts = {}) {
@@ -210,7 +216,7 @@ async function createApp(opts = {}) {
// The conductor's own routes (Codes C9b): its turns, sub-tasks, and the approvals inbox the MCP tools speak to.
// The conductor's own routes (Codes C9b): its turns, sub-tasks, and the approvals inbox the MCP tools speak to.
app.use('/api/v1', createConductorApi({
app.use('/api/v1', createConductorApi({
config, store, keys, harnesses, tasks, conductor: ctx.conductor, approvals: ctx.approvals,
config, store, keys, harnesses, tasks, conductor: ctx.conductor, approvals: ctx.approvals,
- machines: ctx.machines, actorLimits: ctx.actorLimits, log,
+ machines: ctx.machines, fleet: ctx.fleet, actorLimits: ctx.actorLimits, log,
}));
}));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
@@ -111,6 +111,11 @@ function load(env = process.env) {
@@ -111,6 +111,11 @@ function load(env = process.env) {
retentionDays: Math.max(1, int(env.CODES_SESSION_RETENTION_DAYS, 90)),
retentionDays: Math.max(1, int(env.CODES_SESSION_RETENTION_DAYS, 90)),
retentionIntervalMs: Math.max(60_000, int(env.CODES_SESSION_RETENTION_INTERVAL_MS, 86_400_000)),
retentionIntervalMs: Math.max(60_000, int(env.CODES_SESSION_RETENTION_INTERVAL_MS, 86_400_000)),
},
},
+ // OpenVibe.Bot (Codes C9c): a person's machines, the fleet each one reports with its heartbeat, and the device
+ // id Run places a job on. Codes reads it with the person's own token, on the loopback.
+ bot: { internalUrl: trim(env.CODES_BOT_INTERNAL_URL || 'http://127.0.0.1:4630') },
+ // How long a machine's fleet reading is kept before Bot is asked again (the fleet's own cache).
+ fleet: { ttlMs: Math.max(0, int(env.CODES_FLEET_TTL_MS, 60_000)) },
// OpenVibe.Media: the private objects a task's pictures live in. A proof screenshot can show anything on the
// OpenVibe.Media: the private objects a task's pictures live in. A proof screenshot can show anything on the
// person's screen, so it is never public: the bytes go to Media as a private object of the person, and the
// person's screen, so it is never public: the bytes go to Media as a private object of the person, and the
// session shows a short-lived signed URL that only that person can use. Media takes no app keys (C-71): Codes
// session shows a short-lived signed URL that only that person can use. Media takes no app keys (C-71): Codes
@@ -147,7 +152,7 @@ function load(env = process.env) {
@@ -147,7 +152,7 @@ function load(env = process.env) {
// Anthropic one: the conductor is the only Anthropic session).
// Anthropic one: the conductor is the only Anthropic session).
conductor: {
conductor: {
wallMs: Math.max(60_000, int(env.CODES_CONDUCTOR_TURN_MS, 4 * 60 * 60_000)),
wallMs: Math.max(60_000, int(env.CODES_CONDUCTOR_TURN_MS, 4 * 60 * 60_000)),
- subagentAgent: String(env.CODES_CONDUCTOR_SUBAGENT_AGENT || 'codex').trim() || 'codex',
+ subagentAgent: String(env.CODES_CONDUCTOR_SUBAGENT_AGENT || 'auto').trim() || 'auto',
},
},
// The event stream of a task (GET /api/v1/tasks/:id/events): the heartbeat that keeps a connection alive.
// The event stream of a task (GET /api/v1/tasks/:id/events): the heartbeat that keeps a connection alive.
sse: { heartbeatMs: Math.max(1000, int(env.CODES_SSE_HEARTBEAT_MS, 15_000)) },
sse: { heartbeatMs: Math.max(1000, int(env.CODES_SSE_HEARTBEAT_MS, 15_000)) },
@@ -25,19 +25,21 @@
@@ -25,19 +25,21 @@
const { chain, budget, router } = require('../../engine');
const { chain, budget, router } = require('../../engine');
/** Codes' harness ids and the engine's short runner ids for the same harness. */
/** Codes' harness ids and the engine's short runner ids for the same harness. */
-const RUNNER_OF = { 'claude-code': 'claude', codex: 'cx', 'command-code': 'cc', opencode: 'oc', deepseek: 'dsh' };
-const HARNESS_OF = { claude: 'claude-code', cx: 'codex', cc: 'command-code', oc: 'opencode', dsh: 'deepseek' };
+const RUNNER_OF = { 'claude-code': 'claude', codex: 'cx', 'command-code': 'cc', opencode: 'oc', deepseek: 'dsh', 'minimax-code': 'mm' };
+const HARNESS_OF = { claude: 'claude-code', cx: 'codex', cc: 'command-code', oc: 'opencode', dsh: 'deepseek', mm: 'minimax-code' };
-/** The order a runner switch tries the harnesses in (the ladder's order, flattened). */
-const FALLBACK_ORDER = ['cx', 'claude', 'cc', 'oc', 'dsh'];
+/** The order a runner switch tries the harnesses in (the ladder's order, flattened). Never Claude: a sub-task is never
+ * an Anthropic one (Codes C9b's rule), so the conductor's session stays the only Anthropic work. */
+const FALLBACK_ORDER = ['cx', 'mm', 'cc', 'oc', 'dsh'];
-/** Which harnesses may take which kind of task, cheapest first (the engine's ladder, Codes' harnesses). */
+/** Which harnesses may take which kind of task, cheapest first (the engine's ladder, Codes' harnesses). Claude Code is
+ * absent on purpose: it is the conductor's own session, never a sub-task's. */
const TIER_ORDER = {
const TIER_ORDER = {
- basic: ['opencode', 'command-code', 'deepseek'],
- research: ['codex', 'opencode', 'deepseek'],
- standard: ['codex', 'claude-code', 'command-code', 'opencode', 'deepseek'],
- hard: ['codex', 'claude-code', 'deepseek'],
- design: ['codex', 'claude-code', 'opencode'],
+ basic: ['opencode', 'command-code', 'minimax-code', 'deepseek'],
+ research: ['codex', 'minimax-code', 'deepseek', 'opencode'],
+ standard: ['minimax-code', 'codex', 'command-code', 'opencode', 'deepseek'],
+ hard: ['codex', 'minimax-code', 'deepseek'],
+ design: ['codex', 'minimax-code', 'opencode', 'deepseek'],
};
};
const TIER_EFFORT = { basic: 'low', research: 'medium', standard: 'high', hard: 'high', design: 'high' };
const TIER_EFFORT = { basic: 'low', research: 'medium', standard: 'high', hard: 'high', design: 'high' };
@@ -214,13 +216,14 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
@@ -214,13 +216,14 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
);
);
}
}
- /** The engine's own vocabulary for Codes' harnesses: the ladder, the health and the catalogs it routes with. */
- function routing() {
+ /** The engine's own vocabulary for Codes' harnesses: the ladder, the health, the catalogs and the fleet it routes
+ * with. Claude Code is off here: a sub-task is never an Anthropic one (the conductor's session is the exception). */
+ function routing(fleet = null) {
const subagents = {}; const models = {}; const runners = [];
const subagents = {}; const models = {}; const runners = [];
for (const [harnessId, runner] of Object.entries(RUNNER_OF)) {
for (const [harnessId, runner] of Object.entries(RUNNER_OF)) {
const offer = catalog.get(harnessId);
const offer = catalog.get(harnessId);
if (!offer) continue;
if (!offer) continue;
- subagents[runner] = { enabled: true, max: 4, model: null };
+ subagents[runner] = { enabled: harnessId !== 'claude-code', max: 4, model: null };
models[runner] = (offer.agents || []).map((a) => a.model).filter(Boolean);
models[runner] = (offer.agents || []).map((a) => a.model).filter(Boolean);
runners.push(runner);
runners.push(runner);
}
}
@@ -230,17 +233,42 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
@@ -230,17 +233,42 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
for (const tier of router.TIERS) {
for (const tier of router.TIERS) {
ladder[tier] = (TIER_ORDER[tier] || []).map((id) => {
ladder[tier] = (TIER_ORDER[tier] || []).map((id) => {
const runner = RUNNER_OF[id];
const runner = RUNNER_OF[id];
- const offer = catalog.get(id);
- const agent = offer && offer.agents && offer.agents[0];
- return [runner, agent ? agent.model : null, TIER_EFFORT[tier]];
- }).filter(([runner, model]) => subagents[runner] && (!model || models[runner].includes(model)));
+ // No model: each harness runs its own default (the offer's model is the harness's own label, not a
+ // model id to pass on the command line). A caller that names a model still gets it through args.model.
+ return [runner, null, TIER_EFFORT[tier]];
+ }).filter(([runner]) => subagents[runner]);
}
}
return {
return {
- cfg: { subagents, budget: config.budget || {}, fallback: { order: FALLBACK_ORDER.filter((r) => subagents[r]) } },
- health, counts: {}, models, runners, ladder,
+ cfg: { subagents, budget: config.budget || {}, fallback: { order: FALLBACK_ORDER.filter((r) => subagents[r] && subagents[r].enabled) } },
+ health, counts: {}, models, runners, ladder, fleet,
};
};
}
}
+ /**
+ * Why no agent could take the task, in one line. The router's silence is not something a person can act on, so
+ * the task says what each agent is short of: a window that is spent, a balance that is gone, a sign-in that is
+ * missing. Only what was read is said; an agent with room is not mentioned.
+ *
+ * @param {object} [fleet] the fleet per runner: { [runner]: { signed_in, windows, balance_usd } }
+ * @returns {string} the reason, in plain words
+ */
+ function whyNoAgent(fleet) {
+ const rows = Object.entries(fleet || {});
+ if (!rows.length) return 'no agent on your machines could take it right now';
+ const parts = [];
+ for (const [runner, row] of rows) {
+ const harness = HARNESS_OF[runner];
+ const name = (harness && catalog.get(harness) && catalog.get(harness).name) || harness || runner;
+ if (row.signed_in === false) { parts.push(`${name} is not signed in`); continue; }
+ const usd = row.balance_usd == null || row.balance_usd === '' ? NaN : Number(row.balance_usd);
+ if (Number.isFinite(usd) && usd < router.MIN_BALANCE_USD) { parts.push(`${name} has $${usd.toFixed(2)} left`); continue; }
+ const spent = (row.windows || []).filter((w) => router.windowFull(w)).map((w) => w.name).filter(Boolean);
+ if (spent.length) { parts.push(`${name}'s ${spent.join(' and ')} ${spent.length > 1 ? 'are' : 'is'} spent`); continue; }
+ parts.push(`${name} is busy`);
+ }
+ return `no agent has room right now: ${parts.join('; ')}`;
+ }
+
/**
/**
* The next agent for a task, from the engine's router (engine/router.js): the tier of the brief decides the
* The next agent for a task, from the engine's router (engine/router.js): the tier of the brief decides the
* ladder, and the first harness of it that may take the task takes it. The agent that just stopped is skipped.
* ladder, and the first harness of it that may take the task takes it. The agent that just stopped is skipped.
@@ -251,7 +279,9 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
@@ -251,7 +279,9 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
if (!sub || !sub.enabled) return false;
if (!sub || !sub.enabled) return false;
if ((ctx.counts[runner] || 0) >= sub.max) return false;
if ((ctx.counts[runner] || 0) >= sub.max) return false;
const h = ctx.health[runner];
const h = ctx.health[runner];
- return !!h && h.state === 'ok';
+ if (!h || h.state !== 'ok') return false;
+ // The fleet: no job goes to an agent whose window is full or that is not signed in on any machine.
+ return router.roomFor(ctx.fleet, runner);
};
};
const picked = router.pick(prompt, ctx);
const picked = router.pick(prompt, ctx);
const tier = picked ? picked.tier : router.classify(prompt).tier;
const tier = picked ? picked.tier : router.classify(prompt).tier;
@@ -357,8 +387,8 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
@@ -357,8 +387,8 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
* @param {object} [what.result] the job's result (what it changed and what it said)
* @param {object} [what.result] the job's result (what it changed and what it said)
* @returns {object} the plan: an outcome ('done', 'unfinished', 'stopped') or a follow-up attempt
* @returns {object} the plan: an outcome ('done', 'unfinished', 'stopped') or a follow-up attempt
*/
*/
- function decide({ task, attempts, ending, result = null }) {
- const ctx = routing();
+ function decide({ task, attempts, ending, result = null, fleet = null }) {
+ const ctx = routing(fleet);
const upto = (attempts || []).filter((a) => Number(a.n) <= Number(ending.n));
const upto = (attempts || []).filter((a) => Number(a.n) <= Number(ending.n));
const facts = factsOf(result);
const facts = factsOf(result);
const engineAttempts = upto.map((a) => engineAttempt(a, upto, a.id === ending.id ? facts : null));
const engineAttempts = upto.map((a) => engineAttempt(a, upto, a.id === ending.id ? facts : null));
@@ -446,7 +476,7 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
@@ -446,7 +476,7 @@ function createAttempts({ store, config = {}, log = console, harnesses = null })
return {
return {
STATES, REASONS, KINDS, REASON_TEXT, RUNNER_OF, HARNESS_OF,
STATES, REASONS, KINDS, REASON_TEXT, RUNNER_OF, HARNESS_OF,
- list, get, byJob, create, bind, end, spend, decide, present, routing, nextAgent, nameOf, wordsOf, budgetNote,
+ list, get, byJob, create, bind, end, spend, decide, present, routing, nextAgent, whyNoAgent, nameOf, wordsOf, budgetNote,
};
};
}
}
@@ -121,14 +121,17 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
@@ -121,14 +121,17 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
/**
/**
* Create the conductor: the task row, its one attempt and the hand-over turn. The first turn starts at once
* Create the conductor: the task row, its one attempt and the hand-over turn. The first turn starts at once
- * (startNext), so a person who pressed "Start your conductor" sees it begin to work.
+ * (startNext), so a person who pressed "Start your conductor" sees it begin to work. A chosen machine is also
+ * `pin`, its OpenVibe.Bot device id (`dev_…`, Codes C9c): every turn is pinned to it on Run, so the session runs
+ * where its folder and its memory are.
*/
*/
- async function create({ subject, project, folder, machine = null, model = 'opus', safety = 'ask', note = null, key = null } = {}) {
+ async function create({ subject, project, folder, machine = null, pin = null, model = 'opus', safety = 'ask', note = null, key = null } = {}) {
const work = validFolder(folder);
const work = validFolder(folder);
if (!work) throw Object.assign(new Error('the working folder must be an absolute path'), { status: 422, code: 'conductor.folder_invalid' });
if (!work) throw Object.assign(new Error('the working folder must be an absolute path'), { status: 422, code: 'conductor.folder_invalid' });
if (!MODEL.test(String(model || ''))) throw Object.assign(new Error('the model is not a model name'), { status: 422, code: 'conductor.model_invalid' });
if (!MODEL.test(String(model || ''))) throw Object.assign(new Error('the model is not a model name'), { status: 422, code: 'conductor.model_invalid' });
if (!SAFETY.includes(safety)) throw Object.assign(new Error('safety is ask, project or full'), { status: 422, code: 'conductor.safety_invalid' });
if (!SAFETY.includes(safety)) throw Object.assign(new Error('safety is ask, project or full'), { status: 422, code: 'conductor.safety_invalid' });
if (machine && !NODE.test(String(machine))) throw Object.assign(new Error('the machine is not one of the person\'s machines'), { status: 422, code: 'conductor.machine_invalid' });
if (machine && !NODE.test(String(machine))) throw Object.assign(new Error('the machine is not one of the person\'s machines'), { status: 422, code: 'conductor.machine_invalid' });
+ if (pin != null && !/^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(pin))) throw Object.assign(new Error('the pin is not a machine id'), { status: 422, code: 'conductor.machine_invalid' });
if (!subject) throw Object.assign(new Error('a conductor belongs to a person'), { status: 422, code: 'conductor.subject_required' });
if (!subject) throw Object.assign(new Error('a conductor belongs to a person'), { status: 422, code: 'conductor.subject_required' });
const first = fragment() + (note && String(note).trim() ? `\n\n---\n\n## What the owner asked now\n\n${String(note).trim().slice(0, MAX_NOTE)}\n` : '');
const first = fragment() + (note && String(note).trim() ? `\n\n---\n\n## What the owner asked now\n\n${String(note).trim().slice(0, MAX_NOTE)}\n` : '');
@@ -138,9 +141,9 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
@@ -138,9 +141,9 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
let row;
let row;
await store.tx(async () => {
await store.tx(async () => {
row = await db.one(
row = await db.one(
- `INSERT INTO run_tasks (id, subject, project_id, repo, prompt, agent, model, kind, folder, machine, safety, state, idempotency_key, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, 'claude-code', $6, 'conductor', $7, $8, $9, 'queued', $10, $11, $11) RETURNING *`,
- [id, subject, project, repo, first.slice(0, 16000), String(model).slice(0, 100), work, machine || null, safety, key || null, at],
+ `INSERT INTO run_tasks (id, subject, project_id, repo, prompt, agent, model, kind, folder, machine, pin, safety, state, idempotency_key, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, 'claude-code', $6, 'conductor', $7, $8, $9, $10, 'queued', $11, $12, $12) RETURNING *`,
+ [id, subject, project, repo, first.slice(0, 16000), String(model).slice(0, 100), work, machine || null, pin || null, safety, key || null, at],
);
);
});
});
await attempts.create({
await attempts.create({
@@ -232,6 +235,9 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
@@ -232,6 +235,9 @@ function createConductor({ store, config = {}, log = console, run, attempts, tas
job = await run.submit({
job = await run.submit({
subject: task.subject, project: task.project_id, args: turnArgs(task, turn, attempt), key: turn.id,
subject: task.subject, project: task.project_id, args: turnArgs(task, turn, attempt), key: turn.id,
limits: { wall_ms: wallMs, cpu_ms: wallMs, mem_bytes: 4 * 1024 ** 3 }, ttlMs: Math.min(86_400_000, wallMs + 10 * 60_000),
limits: { wall_ms: wallMs, cpu_ms: wallMs, mem_bytes: 4 * 1024 ** 3 }, ttlMs: Math.min(86_400_000, wallMs + 10 * 60_000),
+ // Pinned to the machine the person chose (Codes C9c): the turn resumes a session whose folder and
+ // memory are there, so Run must not place it anywhere else.
+ node: task.pin || null,
});
});
} catch (e) {
} catch (e) {
const why = String((e && (e.detail || e.message)) || 'the turn could not start').slice(0, 500);
const why = String((e && (e.detail || e.message)) || 'the turn could not start').slice(0, 500);
new file mode 100644
new file mode 100644
@@ -0,0 +1,267 @@
@@ -0,0 +1,267 @@
+'use strict';
+
+/**
+ * The fleet (Codes C9c): what each of a person's machines says about its coding agents right now — installed, signed
+ * in, and every limit window with when it frees.
+ *
+ * The machine reports it with its heartbeat to OpenVibe.Bot (OpenVibe.Node `logins`), and Bot answers it for the
+ * machine's owner at GET /api/v1/machines with the person's own token. This module reads that answer, keeps the latest
+ * per machine in codes_machine_fleet, and answers the Agents page, the router and the MCP fleet tool from it. It stores
+ * no key, no token and no account name: the Node reports labels, numbers and times only.
+ *
+ * createFleet({ config, store, fetchImpl, log, now })
+ * refresh(subject, token) read Bot now and replace the stored rows → { ok, machines } or { ok: false, problem }
+ * latest(subject) the stored rows per machine → { machines: [{ id, node_id, name, online, agents }], at }
+ * byHarness(subject) per harness id, the best state across the person's machines (the router's input)
+ * agents(subject, harnesses) one row per agent for the Agents page, usable first
+ *
+ * A Bot that is not configured or does not answer is a problem beside what is stored, never a silent empty fleet: the
+ * page keeps the last reading and says when it was taken.
+ */
+
+const { roomFor, windowFull, FULL_PCT, MIN_BALANCE_USD } = require('../../engine/router');
+const { readBalances } = require('../../engine/balance');
+
+const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const AGENT = /^[a-z0-9-]{1,40}$/;
+const MACHINE = /^[A-Za-z0-9_-]{1,128}$/;
+const STATE = { ok: 0, low: 1, unknown: 2, absent: 3, login: 4, limited: 5, empty: 6 };
+const LOW_PCT = 80;
+
+const iso = (value) => {
+ if (value == null || value === '') return null;
+ const numeric = Number(value);
+ const ms = Number.isFinite(numeric) && numeric > 0 ? (numeric < 1e12 ? numeric * 1000 : numeric) : Date.parse(String(value));
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
+};
+const num = (value) => {
+ const n = Number(value);
+ return value == null || value === '' || !Number.isFinite(n) ? null : n;
+};
+const str = (value, max) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null);
+
+/** One reported window, with only the four things a person may see (a label, two numbers, a time). */
+function windowOf(w) {
+ if (!w || typeof w !== 'object' || Array.isArray(w)) return null;
+ const used = num(w.used ?? w.used_percent ?? w.percent_used);
+ const limit = num(w.limit ?? w.total ?? w.max);
+ let pct = num(w.pct);
+ if (pct == null && used != null && limit != null && limit > 0) pct = Math.round((used / limit) * 100);
+ const name = str(w.name ?? w.label ?? w.id ?? w.window, 40);
+ if (name == null && used == null && limit == null && pct == null) return null;
+ return { name: name || 'window', used, limit, pct: pct == null ? null : Math.round(pct), resets_at: iso(w.resets_at ?? w.reset_at ?? w.resets) };
+}
+
+/** The windows of one agent, merged across machines by name: the fullest reading wins (a stale machine never hides one). */
+function mergeWindows(lists) {
+ const by = new Map();
+ for (const list of lists) {
+ for (const w of list || []) {
+ if (!w) continue;
+ const seen = by.get(w.name);
+ if (!seen || (w.pct ?? -1) > (seen.pct ?? -1)) by.set(w.name, w);
+ }
+ }
+ return [...by.values()];
+}
+
+/** The one-line state the page shows. An agent with room on one machine is usable, even when another machine's window is
+ * full: the meter shows the fullest reading, and the row's machines say which one can take a job. A pay-per-token
+ * agent whose balance is nearly gone is out of credit, whatever its windows say. */
+function stateOf(row) {
+ if (!row) return 'unknown';
+ if (!row.installed) return 'absent';
+ if (!row.signed_in) return 'login';
+ const usd = row.balance_usd == null || row.balance_usd === '' ? NaN : Number(row.balance_usd);
+ if (Number.isFinite(usd) && usd < MIN_BALANCE_USD) return 'empty';
+ const windows = Array.isArray(row.windows) ? row.windows : [];
+ if (row.room !== true && windows.length && windows.every((w) => w.pct != null && w.pct >= FULL_PCT)) return 'limited';
+ if (windows.some((w) => w.pct != null && w.pct >= LOW_PCT)) return 'low';
+ return 'ok';
+}
+
+function createFleet({ config = {}, store, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), balance = null } = {}) {
+ const db = store.db;
+ const base = String((config.bot && config.bot.internalUrl) || '').replace(/\/+$/, '');
+ // The balances of the pay-per-token accounts (Codes C9c): a plan window says how much of a plan is used, a
+ // balance says what is left of the money. Read the way the portal reads it (engine/balance.js), cached for five
+ // minutes: the answer moves in cents, and a task's routing may ask for it as often as it likes.
+ const BALANCE_TTL_MS = 5 * 60_000;
+ let balanceCache = { at: 0, value: null };
+ const readBalancesNow = balance || (() => readBalances({ fetchImpl }));
+ async function balances() {
+ if (balanceCache.value && now() - balanceCache.at < BALANCE_TTL_MS) return balanceCache.value;
+ let value = {};
+ try { value = await readBalancesNow(); } catch (e) {
+ log.warn(`[Codes] the balances could not be read: ${String((e && e.message) || e).slice(0, 160)}`);
+ }
+ balanceCache = { at: now(), value: value || {} };
+ return balanceCache.value;
+ }
+
+ /** One read of Bot's machines for this person. → { status, doc } */
+ async function read(token) {
+ if (!base) throw Object.assign(new Error('OpenVibe.Bot is not configured, so the fleet cannot be read'), { status: 503, code: 'fleet.unavailable' });
+ let res;
+ try {
+ res = await fetchImpl(`${base}/api/v1/machines`, {
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
+ signal: AbortSignal.timeout(8000),
+ });
+ } catch (e) {
+ throw Object.assign(new Error(`OpenVibe.Bot did not answer: ${e.message}`), { status: 503, code: 'fleet.unavailable' });
+ }
+ if (res.status === 401) throw Object.assign(new Error('the session expired'), { status: 401, code: 'fleet.expired' });
+ const doc = await res.json().catch(() => null);
+ if (!res.ok || !doc) {
+ throw Object.assign(new Error((doc && (doc.detail || doc.title)) || `OpenVibe.Bot answered ${res.status}`), { status: res.status === 403 ? 403 : 503, code: 'fleet.unavailable' });
+ }
+ return doc;
+ }
+
+ /**
+ * Read Bot now and replace what Codes holds for each machine whose answer names a fleet. A machine that is gone from
+ * the answer is gone here too; a machine that answers no fleet keeps its last reading (the Node may be older).
+ */
+ async function refresh(subject, token) {
+ if (!USER.test(String(subject || '')) || !token) return { ok: false, problem: { status: 401, detail: 'sign in again to see your machines' } };
+ let doc;
+ try { doc = await read(token); } catch (e) {
+ if (e.status === 401) throw e;
+ log.warn(`[Codes] the fleet could not be read: ${String(e && e.message).slice(0, 200)}`);
+ return { ok: false, problem: { status: e.status || 503, detail: String(e.message || 'the fleet could not be read').slice(0, 300) } };
+ }
+ const machines = (Array.isArray(doc.machines) ? doc.machines : []).filter((m) => m && MACHINE.test(String(m.id || '')));
+ const at = new Date(now()).toISOString();
+ await store.tx(async () => {
+ const ids = machines.map((m) => String(m.id));
+ await db.exec('DELETE FROM codes_machine_fleet WHERE subject = $1 AND NOT (machine = ANY($2))', [subject, ids]);
+ for (const m of machines) {
+ if (!Array.isArray(m.fleet)) continue; // no report from this machine yet: keep what Codes has
+ const machine = String(m.id);
+ await db.exec('DELETE FROM codes_machine_fleet WHERE subject = $1 AND machine = $2', [subject, machine]);
+ for (const entry of m.fleet) {
+ if (!entry || !AGENT.test(String(entry.agent || ''))) continue;
+ const windows = (Array.isArray(entry.windows) ? entry.windows : []).map(windowOf).filter(Boolean).slice(0, 10);
+ await db.exec(
+ `INSERT INTO codes_machine_fleet (subject, machine, node_id, name, agent, installed, signed_in, windows, online, reported_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10::timestamptz, now())
+ ON CONFLICT (subject, machine, agent) DO UPDATE SET node_id = EXCLUDED.node_id, name = EXCLUDED.name,
+ installed = EXCLUDED.installed, signed_in = EXCLUDED.signed_in, windows = EXCLUDED.windows,
+ online = EXCLUDED.online, reported_at = EXCLUDED.reported_at, updated_at = now()`,
+ [subject, machine, str(m.node_id, 128), str(m.name, 120), String(entry.agent), !!entry.installed, !!entry.signed_in,
+ JSON.stringify(windows), m.online !== false, iso(m.fleet_at) || at],
+ );
+ }
+ }
+ });
+ return { ok: true, machines: await latest(subject).then((x) => x.machines), problem: null };
+ }
+
+ /** The stored fleet, per machine, newest reading first. */
+ async function latest(subject) {
+ if (!USER.test(String(subject || ''))) return { ok: false, machines: [], at: null };
+ const rows = await db.many(
+ 'SELECT * FROM codes_machine_fleet WHERE subject = $1 ORDER BY name ASC NULLS LAST, machine ASC, agent ASC',
+ [subject],
+ );
+ const by = new Map();
+ for (const row of rows) {
+ const key = row.machine;
+ const machine = by.get(key) || { id: key, node_id: row.node_id || null, name: row.name || null, online: !!row.online, reported_at: null, agents: [] };
+ if (row.reported_at && (!machine.reported_at || String(row.reported_at) > machine.reported_at)) machine.reported_at = iso(row.reported_at);
+ machine.agents.push({
+ agent: row.agent, installed: !!row.installed, signed_in: !!row.signed_in,
+ windows: Array.isArray(row.windows) ? row.windows : [],
+ });
+ by.set(key, machine);
+ }
+ const machines = [...by.values()];
+ const times = machines.map((m) => m.reported_at).filter(Boolean).sort();
+ return { ok: true, machines, at: times.length ? times[times.length - 1] : null };
+ }
+
+ /**
+ * Per harness id, the best state across the person's machines — what the router reads. An agent is usable when at
+ * least one machine has it installed and signed in with room; its windows are the fullest reading across machines.
+ * A pay-per-token agent also carries its balance, when one was read: an agent whose money is nearly gone may not
+ * take a job (the router's rule), so the reading belongs with the rest of its state.
+ */
+ async function byHarness(subject) {
+ const { machines } = await latest(subject);
+ const money = await balances();
+ const out = {};
+ for (const machine of machines) {
+ for (const a of machine.agents) {
+ const cur = out[a.agent] || (out[a.agent] = {
+ installed: false, signed_in: false, windows: [], machines: [], _lists: [], _room: false, _best: null,
+ });
+ cur.installed = cur.installed || a.installed;
+ cur.signed_in = cur.signed_in || a.signed_in;
+ cur._lists.push(a.windows);
+ const room = a.installed && a.signed_in && !a.windows.some((w) => windowFull(w, now()));
+ cur._room = cur._room || room;
+ cur.machines.push({ machine: machine.id, node_id: machine.node_id, name: machine.name, online: machine.online, room });
+ if (room && !cur._best) cur._best = machine.id;
+ }
+ }
+ for (const [agent, row] of Object.entries(out)) {
+ row.windows = mergeWindows(row._lists);
+ row.machine = row._best || (row.machines[0] && row.machines[0].machine) || null;
+ const account = money[agent];
+ row.balance_usd = account ? account.usd : null;
+ row.balance_at = account ? account.at : null;
+ row.room = row._room || roomFor({ [agent]: row }, agent, now());
+ delete row._lists; delete row._best;
+ }
+ return out;
+ }
+
+ /** The fleet per runner id (cx, oc, cc, mm), what engine/router.js reads. */
+ async function forRouter(subject, harnessOf) {
+ const by = await byHarness(subject);
+ const out = {};
+ for (const [harness, row] of Object.entries(by)) {
+ const runner = harnessOf ? harnessOf(harness) : harness;
+ if (!runner) continue;
+ 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,
+ };
+ }
+ return out;
+ }
+
+ /** One row per agent, for the Agents page: usable and with the most room first, then sign-in needed, then limited. */
+ async function agents(subject, harnesses = null) {
+ const by = await byHarness(subject);
+ const rows = Object.entries(by).map(([agent, row]) => {
+ const offer = harnesses && harnesses.get ? harnesses.get(agent) : null;
+ const state = stateOf(row);
+ const used = row.windows.reduce((max, w) => Math.max(max, w.pct == null ? 0 : w.pct), 0);
+ return {
+ agent, name: (offer && offer.name) || agent, state, installed: row.installed, signed_in: row.signed_in,
+ windows: row.windows, machines: row.machines, machine: row.machine, used,
+ balance_usd: row.balance_usd ?? null, balance_at: row.balance_at ?? null,
+ };
+ });
+ return rows.sort((a, b) => (STATE[a.state] ?? 9) - (STATE[b.state] ?? 9) || a.used - b.used || a.name.localeCompare(b.name));
+ }
+
+ /**
+ * The OpenVibe.Bot device id (`dev_…`) of one of the person's machines, given the id Codes knows it by (`nod_…`, or
+ * the device id itself): what a Run job is pinned to (Codes C9c; Run's `job.node`). null when Bot's list has no such
+ * machine; a Bot that does not answer throws (with a status), so a caller never pins to a guess.
+ */
+ async function deviceFor(token, nodeId) {
+ if (!token) throw Object.assign(new Error('sign in again to read your machines'), { status: 401, code: 'fleet.expired' });
+ const doc = await read(token);
+ const hit = (Array.isArray(doc.machines) ? doc.machines : [])
+ .find((m) => m && (m.id === nodeId || m.node_id === nodeId) && MACHINE.test(String(m.id || '')));
+ return hit ? { device: String(hit.id), node_id: str(hit.node_id, 128), name: str(hit.name, 120), online: hit.online !== false } : null;
+ }
+
+ return { refresh, latest, byHarness, forRouter, agents, deviceFor, balances, stateOf, windowOf, mergeWindows, enabled: !!base };
+}
+
+module.exports = { createFleet, stateOf, windowOf, mergeWindows, FULL_PCT, LOW_PCT, MIN_BALANCE_USD, STATE };
@@ -18,6 +18,8 @@
@@ -18,6 +18,8 @@
* anywhere but api.github.com.
* anywhere but api.github.com.
*/
*/
+const { createShip } = require('./ship');
+
const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,38})$/;
const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,38})$/;
const NAME = /^[A-Za-z0-9._-]{1,100}$/;
const NAME = /^[A-Za-z0-9._-]{1,100}$/;
const PR_URL = /^https:\/\/github\.com\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/pull\/(\d{1,10})\/?$/;
const PR_URL = /^https:\/\/github\.com\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/pull\/(\d{1,10})\/?$/;
@@ -58,10 +60,12 @@ function ciWord(status) {
@@ -58,10 +60,12 @@ function ciWord(status) {
return ({ success: 'Passed', failure: 'Failed', error: 'Errored', pending: 'Running' })[state] || null;
return ({ success: 'Passed', failure: 'Failed', error: 'Errored', pending: 'Running' })[state] || null;
}
}
-function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch, log = console } = {}) {
+function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch, log = console, ship = null } = {}) {
const token = String((config.github && config.github.token) || '').trim();
const token = String((config.github && config.github.token) || '').trim();
const cache = new Map();
const cache = new Map();
const searches = new Map();
const searches = new Map();
+ // The host's own deploy signal: the service's /release.json, which `ovhost status` reads too (Codes C9c).
+ const deployments = ship || createShip({ config, fetchImpl, log });
const headers = () => ({
const headers = () => ({
Accept: 'application/vnd.github+json',
Accept: 'application/vnd.github+json',
@@ -171,10 +175,14 @@ function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch,
@@ -171,10 +175,14 @@ function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch,
url, number: parts.number, owner: parts.owner, name: parts.name,
url, number: parts.number, owner: parts.owner, name: parts.name,
state: doc.merged ? 'merged' : (doc.state === 'closed' ? 'closed' : 'open'),
state: doc.merged ? 'merged' : (doc.state === 'closed' ? 'closed' : 'open'),
merge: mergeWord(doc),
merge: mergeWord(doc),
+ merged: !!doc.merged,
+ merge_commit_sha: doc.merge_commit_sha ? String(doc.merge_commit_sha).slice(0, 40) : null,
ci,
ci,
title: doc.title ? String(doc.title).slice(0, 200) : null,
title: doc.title ? String(doc.title).slice(0, 200) : null,
branch: doc.head && doc.head.ref ? String(doc.head.ref).slice(0, 120) : null,
branch: doc.head && doc.head.ref ? String(doc.head.ref).slice(0, 120) : null,
base: doc.base && doc.base.ref ? String(doc.base.ref).slice(0, 120) : null,
base: doc.base && doc.base.ref ? String(doc.base.ref).slice(0, 120) : null,
+ // CI, merge, then the deploy the host reports for the service this repository builds.
+ deploy: await deployments.deployOf({ repo: `${parts.owner}/${parts.name}`, mergeCommitSha: doc.merge_commit_sha || null, merged: !!doc.merged }),
checked_at: new Date().toISOString(),
checked_at: new Date().toISOString(),
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+'use strict';
+
+/**
+ * Telling a person a task finished, or needs them (Codes C9c). One OpenVibe notification through the Network — the only
+ * notification store (ADR-020) — honouring the two switches in Settings (notify_done, notify_needs).
+ *
+ * The notice is recorded once per task and state in codes_notices in the same act that sends it, so a redelivered
+ * Run event, a fallback read or two racing ticks never tell the person twice. The Action Center needs no push: it reads
+ * the same tasks (codes.task@1 → common.action-item@1), so a task that ended is there whether or not a notification
+ * could be sent.
+ *
+ * createNotices({ store, config, notify, userSettings, log })
+ * done(task, note) → { sent, reason?, skipped? }
+ * needs(task, note) → { sent, reason?, skipped? }
+ *
+ * A notification that cannot be sent never stops the thing it is about, and a switched-off choice is not an error.
+ */
+
+const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
+const KINDS = new Set(['done', 'needs']);
+
+const text = (value, max) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim().slice(0, max);
+
+function createNotices({ store, config = {}, notify = null, userSettings = null, log = console } = {}) {
+ const db = store.db;
+
+ /**
+ * One notice for one task and state. The row is claimed first: if another caller already claimed it, nothing is
+ * sent. The choice and the push are best effort and are said in the answer, never thrown.
+ */
+ async function send(task, kind, note) {
+ if (!task || !TASK.test(String(task.id || '')) || !KINDS.has(kind)) return { sent: false, reason: 'bad_input' };
+ if (!task.subject) return { sent: false, reason: 'no_subject' };
+ const claimed = await db.maybe(
+ `INSERT INTO codes_notices (task_id, kind, result) VALUES ($1, $2, $3)
+ ON CONFLICT (task_id, kind) DO NOTHING RETURNING task_id`,
+ [task.id, kind, text(note, 300) || null],
+ );
+ if (!claimed) return { sent: false, reason: 'already' };
+ if (!notify) return { sent: false, reason: 'not_configured' };
+ let settings = { notify_done: true, notify_needs: true };
+ try { if (userSettings) settings = await userSettings.get(task.subject); } catch { /* the default is to tell */ }
+ if (kind === 'done' && settings.notify_done === false) return { sent: false, reason: 'switched_off' };
+ if (kind === 'needs' && settings.notify_needs === false) return { sent: false, reason: 'switched_off' };
+ const base = String(config.baseUrl || '').replace(/\/+$/, '');
+ const line = text(note, 300);
+ try {
+ const out = await notify.push({
+ subject: task.subject,
+ type: 'SYSTEM',
+ title: kind === 'done' ? 'Your task is done' : 'Your task needs you',
+ message: line || (kind === 'done' ? 'It finished. Open it to see what changed.' : 'It stopped and needs a person to carry on.'),
+ url: `${base}/tasks/${encodeURIComponent(task.id)}`,
+ priority: kind === 'needs' ? 'high' : 'normal',
+ category: 'codes',
+ icon: kind === 'done' ? '✅' : '🙋',
+ });
+ return out || { sent: true };
+ } catch (e) {
+ log.warn(`[Codes] notice ${kind} for ${task.id} was not sent: ${String((e && e.message) || e).slice(0, 200)}`);
+ return { sent: false, reason: 'network_error' };
+ }
+ }
+
+ /** "Done": the task's outcome is done. */
+ const done = (task, note = null) => send(task, 'done', note);
+ /** "Needs you": an unfinished task, a stop at its budget, or a task that could not start. */
+ const needs = (task, note = null) => send(task, 'needs', note);
+
+ return { send, done, needs, KINDS };
+}
+
+module.exports = { createNotices, KINDS };
@@ -50,9 +50,11 @@ function createRunClient({ config, fetchImpl = globalThis.fetch, now = () => Dat
@@ -50,9 +50,11 @@ function createRunClient({ config, fetchImpl = globalThis.fetch, now = () => Dat
}
}
/**
/**
* A coding job for `subject` in `project`, only on that person's own machine. A conductor's turn asks for more: its
* A coding job for `subject` in `project`, only on that person's own machine. A conductor's turn asks for more: its
- * wall time (a session that works through a plan), its TTL and its own memory, all bounded here.
+ * wall time (a session that works through a plan), its TTL and its own memory, all bounded here; and when its
+ * machine is known (`node`, the OpenVibe.Bot device id `dev_…`), the job is pinned to it (contracts 0.250.0): Run
+ * runs it nowhere else, and refuses it `409 run.job.node_offline` when the machine is away.
*/
*/
- async function submit({ subject, project, args, key, limits = null, ttlMs = null }) {
+ async function submit({ subject, project, args, key, limits = null, ttlMs = null, node = null }) {
const wall = Math.max(1000, Math.min(90 * 60_000, Number(limits && limits.wall_ms) || Number(args && args.limits && args.limits.ms) || 30 * 60_000));
const wall = Math.max(1000, Math.min(90 * 60_000, Number(limits && limits.wall_ms) || Number(args && args.limits && args.limits.ms) || 30 * 60_000));
const body = {
const body = {
class: 'code', artifact: { name: 'openvibe-codes', version: JOB_PROTOCOL }, args,
class: 'code', artifact: { name: 'openvibe-codes', version: JOB_PROTOCOL }, args,
@@ -64,6 +66,9 @@ function createRunClient({ config, fetchImpl = globalThis.fetch, now = () => Dat
@@ -64,6 +66,9 @@ function createRunClient({ config, fetchImpl = globalThis.fetch, now = () => Dat
ttl_ms: Math.max(60_000, Math.min(86_400_000, Number(ttlMs) || 45 * 60_000)),
ttl_ms: Math.max(60_000, Math.min(86_400_000, Number(ttlMs) || 45 * 60_000)),
egress: 'public',
egress: 'public',
requirements: { kind: 'run.code', mobility: 'job', latency_class: 'background', objective: 'balanced', trust: ['user-owned'] },
requirements: { kind: 'run.code', mobility: 'job', latency_class: 'background', objective: 'balanced', trust: ['user-owned'] },
+ // The machine the job runs on, pinned: a conductor's folder and memory are there (Codes C9c). Absent, Run
+ // places the job where it fits, as before.
+ ...(node ? { node: String(node).slice(0, 64) } : {}),
};
};
const out = await call('POST', '/api/v1/jobs', { subject, project, body, key });
const out = await call('POST', '/api/v1/jobs', { subject, project, body, key });
return out.job;
return out.job;
@@ -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 }) {
+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 }) {
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).
@@ -169,7 +169,23 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -169,7 +169,23 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
// The answer is cached for a minute, so a task that ends does not ask again for every attempt.
// The answer is cached for a minute, so a task that ends does not ask again for every attempt.
const connectionCache = new Map();
const connectionCache = new Map();
const CONNECTION_TTL_MS = 60_000;
const CONNECTION_TTL_MS = 60_000;
+ // The fleet the person's machines reported (Codes C9c), cached for a minute like the connections: one task's
+ // routing and its follow-ups read the same reading, and a page view does not ask Bot for every task.
+ const fleetCache = new Map();
+ const FLEET_TTL_MS = Number.isFinite(Number(config.fleet && config.fleet.ttlMs)) ? Math.max(0, Number(config.fleet.ttlMs)) : 60_000;
+ let providerMap = null;
let harnessInfoCache = null;
let harnessInfoCache = null;
+ async function fleetFor(subject) {
+ if (!fleet || !subject) return null;
+ const hit = fleetCache.get(subject);
+ if (hit && hit.until > store.now()) return hit.value;
+ let value = null;
+ try { value = await fleet.forRouter(subject, (agent) => attemptStore.RUNNER_OF[agent] || null); } catch (e) {
+ log.warn(`[Codes] the fleet of ${subject} could not be read: ${String((e && e.message) || e).slice(0, 200)}`);
+ }
+ fleetCache.set(subject, { value, until: store.now() + FLEET_TTL_MS });
+ return value;
+ }
async function personConnections(subject) {
async function personConnections(subject) {
if (!connections || !subject) return null;
if (!connections || !subject) return null;
const hit = connectionCache.get(subject);
const hit = connectionCache.get(subject);
@@ -460,42 +476,58 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -460,42 +476,58 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
async function start({ subject, project, args, via = null, key = null, actorRef = null }) {
async function start({ subject, project, args, via = null, key = null, actorRef = null }) {
const id = store.newId('tsk');
const id = store.newId('tsk');
const at = store.now();
const at = store.now();
+ // A task with no agent named is `auto` (Codes C9c): the router reads the fleet — who is signed in, whose window
+ // still has room — and the task is started on the agent it picks. Never a fixed agent, and never an Anthropic one.
+ let agent = args.agent || null;
+ let model = args.model || null;
+ let why = agent ? `the first try, with ${attemptStore.nameOf(agent)}` : 'the first try, with the agent that fits the task';
+ // Nothing may take it: the task says why, and needs a person. It never starts on an agent without room.
+ let refusal = null;
+ if (!agent) {
+ const f = await fleetFor(subject);
+ const picked = attemptStore.nextAgent(args.prompt, { ...attemptStore.routing(f), fleet: f }, null);
+ if (picked) { agent = picked.agent; model = picked.model || null; why = picked.why || why; }
+ else refusal = attemptStore.whyNoAgent(f);
+ }
+ 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;
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)
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 *`,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $13) RETURNING *`,
- [id, subject, project, args.repo || 'bench://', args.ref, args.prompt, args.agent, !!args.push, !!(args.push && args.push.pull_request), via, key, actorRefValue, at]);
+ [id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, at]);
await taskOutbox.emitTask(inserted, null);
await taskOutbox.emitTask(inserted, null);
return inserted;
return inserted;
});
});
// The task's first attempt: every Run job is one attempt, and this one is the task as it was asked.
// The task's first attempt: every Run job is one attempt, and this one is the task as it was asked.
// The budget of the attempt: the task's limits (a benchmark's per-task limits) or the person's default.
// The budget of the attempt: the task's limits (a benchmark's per-task limits) or the person's default.
- const budget = budgetFor(args.limits);
+ const budget = budgetFor(runArgs.limits);
const first = await attemptStore.create({
const first = await attemptStore.create({
- task: inserted, agent: args.agent || null, model: args.model || null,
- why: args.agent ? `the first try, with ${attemptStore.nameOf(args.agent)}` : 'the first try, with the agent that fits the task',
+ task: inserted, agent: agent || null, model: model || null, why,
kind: 'first', state: 'queued', budget,
kind: 'first', state: 'queued', budget,
});
});
let job;
let job;
try {
try {
+ if (refusal) throw Object.assign(new Error(refusal), { code: 'codes.no_agent' });
// The job's harness runs with a short-lived run token that names the person (Codes C3): the environment
// The job's harness runs with a short-lived run token that names the person (Codes C3): the environment
// travels with the job's input and the machine's worker hands it to the harness.
// travels with the job's input and the machine's worker hands it to the harness.
- const funding = await attemptEnv({ subject, taskId: id, attemptId: first.attempt.id, agent: args.agent || null, budget });
+ const funding = await attemptEnv({ subject, taskId: id, attemptId: first.attempt.id, agent: agent || null, budget });
await attemptStore.bind(first.attempt.id, { run_mode: funding.mode });
await attemptStore.bind(first.attempt.id, { run_mode: funding.mode });
- job = await run.submit({ subject, project, args: funding.env ? { ...args, env: funding.env } : args, key: id });
+ job = await run.submit({ subject, project, args: funding.env ? { ...runArgs, env: funding.env } : runArgs, key: id });
} catch (e) {
} catch (e) {
const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
- await attemptStore.end(first.attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
- return await store.tx(async () => {
- 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
+ await attemptStore.end(first.attempt.id, { state: 'failed', reason: refusal ? 'refused' : 'error', finished_at: store.iso() });
+ const failed = await store.tx(async () => {
+ const row = 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 *`, [id, why, `the task could not start: ${why}`, store.iso(), store.now()]);
WHERE id = $1 RETURNING *`, [id, why, `the task could not start: ${why}`, store.iso(), store.now()]);
- await taskOutbox.emitTask(failed, 'queued');
+ await taskOutbox.emitTask(row, 'queued');
if (benchAttemptEnded && actorRef && asObject(actorRef).bench) {
if (benchAttemptEnded && actorRef && asObject(actorRef).bench) {
- await benchAttemptEnded(failed).catch((publishError) => log.warn(`[Codes] benchmark publish failed for task ${failed.id}: ${String(publishError && publishError.message).slice(0, 200)}`));
+ await benchAttemptEnded(row).catch((publishError) => log.warn(`[Codes] benchmark publish failed for task ${row.id}: ${String(publishError && publishError.message).slice(0, 200)}`));
}
}
- return failed;
+ return row;
});
});
+ if (notices) await notices.needs(failed, `the task could not start: ${why}`);
+ 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' });
return await store.tx(async () => {
return await store.tx(async () => {
@@ -558,6 +590,21 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -558,6 +590,21 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
});
});
}
}
+ /**
+ * Tell the person once about a task's outcome (Codes C9c): done, or needs them. The notice module claims the task
+ * and state before it sends, so a redelivered event or a second caller changes nothing. A stopped task is the
+ * person's own act and tells them nothing.
+ */
+ async function notifyOutcome(row) {
+ if (!notices || !row) return;
+ if (row.outcome === 'done') {
+ const summary = String((asObject(row.result) || {}).summary || '').trim();
+ await notices.done(row, summary || row.outcome_note || 'the task finished');
+ } else if (row.outcome === 'unfinished') {
+ await notices.needs(row, row.outcome_note || row.error || 'the task needs a person to carry on');
+ }
+ }
+
/**
/**
* An attempt ended: the engine decides what happens next (server/domain/attempts.js) and this carries the
* An attempt ended: the engine decides what happens next (server/domain/attempts.js) and this carries the
* decision out — a follow-up attempt, or the task's outcome in plain words.
* decision out — a follow-up attempt, or the task's outcome in plain words.
@@ -581,20 +628,24 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -581,20 +628,24 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const note = ended && ended.reason === 'budget' && ['usd', 'tokens'].includes(ended.budget_hit)
const note = ended && ended.reason === 'budget' && ['usd', 'tokens'].includes(ended.budget_hit)
? attemptStore.budgetNote(ended)
? attemptStore.budgetNote(ended)
: (done ? 'benchmark attempt finished' : (row.error || 'benchmark attempt failed'));
: (done ? 'benchmark attempt finished' : (row.error || 'benchmark attempt failed'));
- return 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);
+ return saved;
}
}
const attempts = await attemptStore.list(row.id);
const attempts = await attemptStore.list(row.id);
const ending = attempts[attempts.length - 1];
const ending = attempts[attempts.length - 1];
if (!ending || !ENDED.has(ending.state)) return row;
if (!ending || !ENDED.has(ending.state)) return row;
// The proof this attempt's machine collected: the items are kept, and each one is an event on the stream.
// The proof this attempt's machine collected: the items are kept, and each one is an event on the stream.
const collected = await recordProof(row, ending);
const collected = await recordProof(row, ending);
- const plan = await attemptStore.decide({ task: row, attempts, ending, result: row.result });
+ const plan = await attemptStore.decide({ task: row, attempts, ending, result: row.result, fleet: await fleetFor(row.subject) });
if (plan.outcome) {
if (plan.outcome) {
// The outcome in plain words: what the engine decided, and what the proof says about it.
// The outcome in plain words: what the engine decided, and what the proof says about it.
const outcome = taskProof.outcomeOf(collected, { state: plan.outcome, note: plan.note });
const outcome = taskProof.outcomeOf(collected, { state: plan.outcome, note: plan.note });
- return 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);
+ return saved;
}
}
if (!plan.kind) return row;
if (!plan.kind) return row;
const done = await startFollowUp(row, plan, ending);
const done = await startFollowUp(row, plan, ending);
@@ -649,6 +700,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -649,6 +700,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
await attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
await attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
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);
return { attempt, created: true, row: failed, error: e.message };
return { attempt, created: true, row: failed, error: e.message };
}
}
let job;
let job;
@@ -663,6 +715,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -663,6 +715,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
await attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
await attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
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);
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' });
@@ -739,6 +792,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -739,6 +792,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
await attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
await attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
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);
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' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+
+/**
+ * Ship tracking (Codes C9c): after CI and the merge, is the service that repository builds actually deployed?
+ *
+ * The signal is the host's own: `ovhost status <service>` compares the service's `GET /release.json` (the deployed
+ * commit, set at boot) with the commit it expected, and Codes reads that same `/release.json` from the service's public
+ * origin. A PR's merge commit counts as deployed when the service reports it, or a later main commit that contains it
+ * (GitHub's compare says ahead/identical). Nothing is guessed from a timer: when neither answer is available the state
+ * is "not read" with the release the service does report.
+ *
+ * createShip({ config, fetchImpl, manifests, log })
+ * deployOf({ repo, mergeCommitSha, merged }) → { state, service, origin, release, booted_at, checked_at, detail? }
+ *
+ * state: 'deployed' | 'pending' (merged, not live yet) | 'waiting' (not merged) | 'unknown' (nothing to read).
+ */
+
+const REPO = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/;
+const SHA = /^[0-9a-f]{7,40}$/i;
+
+/** The service a repository builds, from the contracts' manifests (a manifest without an origin is not deployable). */
+function serviceFor(manifests, repo) {
+ const m = REPO.exec(String(repo || ''));
+ if (!m) return null;
+ const want = `${m[1]}/${m[2]}`.toLowerCase();
+ return (manifests || []).find((s) => s && typeof s.repository === 'string'
+ && s.repository.toLowerCase() === want && typeof s.publicOrigin === 'string' && /^https:\/\//.test(s.publicOrigin)) || null;
+}
+
+function createShip({ config = {}, fetchImpl = globalThis.fetch, manifests = null, log = console } = {}) {
+ const list = manifests || require('openvibe-contracts').services.manifests;
+ const token = String((config.github && config.github.token) || '').trim();
+ const base = String(process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\/+$/, '');
+
+ async function releaseOf(origin) {
+ const res = await fetchImpl(`${String(origin).replace(/\/+$/, '')}/release.json`, {
+ headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(6000),
+ });
+ const doc = await res.json().catch(() => null);
+ return res.ok && doc && typeof doc === 'object' ? doc : null;
+ }
+
+ /** Does the deployed release contain the PR's merge commit? GitHub's compare answers when the shas differ. */
+ async function contains(repo, sha, release) {
+ if (!SHA.test(String(release || ''))) return null;
+ try {
+ const res = await fetchImpl(`${base}/repos/${repo}/compare/${sha}...${release}`, {
+ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'openvibe-codes', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
+ signal: AbortSignal.timeout(6000),
+ });
+ if (!res.ok) return null;
+ const doc = await res.json().catch(() => null);
+ return doc && typeof doc.status === 'string' ? ['ahead', 'identical'].includes(doc.status) : null;
+ } catch { return null; }
+ }
+
+ /**
+ * A pull request's deploy state.
+ *
+ * @param {object} what
+ * @param {string} what.repo owner/name
+ * @param {string|null} [what.mergeCommitSha] the merge commit GitHub reports
+ * @param {boolean} [what.merged] whether the PR is merged
+ * @returns {Promise<object>} { state, service, origin, release, booted_at, checked_at, detail? }
+ */
+ async function deployOf({ repo, mergeCommitSha = null, merged = false } = {}) {
+ const checked_at = new Date().toISOString();
+ const service = serviceFor(list, repo);
+ if (!service) return { state: 'unknown', service: null, origin: null, release: null, booted_at: null, checked_at, detail: 'Codes knows no service for this repository' };
+ let doc = null;
+ try { doc = await releaseOf(service.publicOrigin); } catch (e) {
+ log.warn(`[Codes] ${service.id}: /release.json could not be read: ${String(e && e.message).slice(0, 160)}`);
+ }
+ const release = doc && typeof doc.release === 'string' ? doc.release : null;
+ const booted = doc && typeof doc.booted_at === 'string' ? doc.booted_at : null;
+ const out = { service: service.id, origin: service.publicOrigin, release, booted_at: booted, checked_at };
+ if (!merged || !mergeCommitSha) return { ...out, state: 'waiting', detail: 'the pull request is not merged yet' };
+ if (!release) return { ...out, state: 'unknown', detail: 'the service did not answer /release.json, so Codes cannot tell whether it is live' };
+ if (release === mergeCommitSha || release.startsWith(mergeCommitSha) || mergeCommitSha.startsWith(release)) {
+ return { ...out, state: 'deployed', detail: null };
+ }
+ const ahead = await contains(repo, mergeCommitSha, release);
+ if (ahead === true) return { ...out, state: 'deployed', detail: null };
+ if (ahead === false) return { ...out, state: 'pending', detail: 'merged, not deployed yet' };
+ return { ...out, state: 'unknown', detail: `the service reports release ${release}, which Codes could not compare with the merge` };
+ }
+
+ return { deployOf, releaseOf, contains, enabled: true };
+}
+
+module.exports = { createShip, serviceFor, REPO };
@@ -33,6 +33,19 @@ const age = (value) => {
@@ -33,6 +33,19 @@ const age = (value) => {
const mins = Math.max(0, Math.floor((Date.now() - ms) / 60000));
const mins = Math.max(0, Math.floor((Date.now() - ms) / 60000));
return mins < 60 ? `${mins}m ago` : mins < 1440 ? `${Math.floor(mins / 60)}h ago` : `${Math.floor(mins / 1440)}d ago`;
return mins < 60 ? `${mins}m ago` : mins < 1440 ? `${Math.floor(mins / 60)}h ago` : `${Math.floor(mins / 1440)}d ago`;
};
};
+/** When a limit window frees, in the one phrase the page uses: "frees in 2 d · Tue 14:00 UTC". */
+const whenUtc = (value) => {
+ const ms = Date.parse(String(value || ''));
+ if (!Number.isFinite(ms)) return null;
+ const text = new Date(ms).toLocaleString('en-GB', { weekday: 'short', hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'UTC' }).replace(',', '');
+ const mins = Math.round((ms - Date.now()) / 60000);
+ const soon = mins <= 0 ? 'now' : mins < 60 ? `in ${mins} min` : mins < 2880 ? `in ${Math.round(mins / 60)} h` : `in ${Math.round(mins / 1440)} d`;
+ return text ? `frees ${soon} · ${text} UTC` : null;
+};
+const FLEET_STATE = {
+ ok: ['Has room', 'ok'], low: ['Nearly full', 'skip'], login: ['Sign in', 'bad'], limited: ['Full', 'bad'],
+ empty: ['Out of credit', 'bad'], absent: ['Not installed', 'skip'], unknown: ['Not reported', 'skip'],
+};
const repoDetails = (repo) => {
const repoDetails = (repo) => {
try {
try {
const url = new URL(String(repo || ''));
const url = new URL(String(repo || ''));
@@ -54,7 +67,7 @@ const PROVIDER_NAMES = { anthropic: 'Anthropic', openai: 'OpenAI', deepseek: 'De
@@ -54,7 +67,7 @@ const PROVIDER_NAMES = { anthropic: 'Anthropic', openai: 'OpenAI', deepseek: 'De
function createC4Routes(ctx) {
function createC4Routes(ctx) {
const r = asyncRouter();
const r = asyncRouter();
- const { config, store, harnesses, tasks, sso, connections, machines, userSettings, github, usage, conductor, approvals } = ctx;
+ const { config, store, harnesses, tasks, sso, connections, machines, userSettings, github, usage, conductor, approvals, fleet } = ctx;
const db = store.db;
const db = store.db;
const form = express.urlencoded({ extended: false, limit: '16kb' });
const form = express.urlencoded({ extended: false, limit: '16kb' });
const page = (req, res, title, body, extra = {}) => {
const page = (req, res, title, body, extra = {}) => {
@@ -222,10 +235,17 @@ ${patch ? `<details class="c4-fold c4-fold-wide"><summary><span class="c4-row-la
@@ -222,10 +235,17 @@ ${patch ? `<details class="c4-fold c4-fold-wide"><summary><span class="c4-row-la
}
}
const pr = await github.pullRequest(href);
const pr = await github.pullRequest(href);
const stateWord = pr.problem ? 'Not read' : (pr.state === 'merged' ? 'Merged' : pr.state === 'closed' ? 'Closed' : 'Open');
const stateWord = pr.problem ? 'Not read' : (pr.state === 'merged' ? 'Merged' : pr.state === 'closed' ? 'Closed' : 'Open');
+ // The deploy: the service's own /release.json, the same signal `ovhost status` reads (Codes C9c).
+ const deploy = asObject(pr.deploy) || {};
+ const deployWord = ({ deployed: 'Deployed', pending: 'Not live yet', waiting: 'Waiting for the merge', unknown: 'Not read' })[deploy.state] || 'Not read';
+ const deployKind = deploy.state === 'deployed' ? 'ok' : (deploy.state === 'pending' ? 'open' : 'skip');
+ const deployNote = deploy.state === 'deployed'
+ ? `release ${esc(String(deploy.release || '').slice(0, 12))}${deploy.booted_at ? `, booted ${esc(age(deploy.booted_at))}` : ''}`
+ : (deploy.detail ? esc(deploy.detail) : (deploy.service ? esc(deploy.service) : ''));
const stateKind = pr.problem ? 'skip' : (pr.state === 'merged' ? 'ok' : pr.state === 'closed' ? 'bad' : 'open');
const stateKind = pr.problem ? 'skip' : (pr.state === 'merged' ? 'ok' : pr.state === 'closed' ? 'bad' : 'open');
return `<div class="c4-rows"><div class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${stateKind}" aria-hidden="true"></span><span class="c4-row-label">Pull request #${esc(pr.number || '')}</span><span class="c4-row-note">${esc(pr.title || `${pr.owner || ''}/${pr.name || ''}`)}</span>${pill(stateWord, stateKind)}</div>
return `<div class="c4-rows"><div class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-${stateKind}" aria-hidden="true"></span><span class="c4-row-label">Pull request #${esc(pr.number || '')}</span><span class="c4-row-note">${esc(pr.title || `${pr.owner || ''}/${pr.name || ''}`)}</span>${pill(stateWord, stateKind)}</div>
<div class="c4-pr-actions"><a class="c4-opill c4-opill-primary" href="${esc(href)}" rel="noopener noreferrer">${icon('<path d="M14 3h7v7M21 3l-9 9"/><path d="M19 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5"/>')}<span>Open on GitHub</span></a>${pr.branch ? `<span class="c4-chip">${esc(pr.branch)} → ${esc(pr.base || 'base')}</span>` : ''}</div>
<div class="c4-pr-actions"><a class="c4-opill c4-opill-primary" href="${esc(href)}" rel="noopener noreferrer">${icon('<path d="M14 3h7v7M21 3l-9 9"/><path d="M19 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5"/>')}<span>Open on GitHub</span></a>${pr.branch ? `<span class="c4-chip">${esc(pr.branch)} → ${esc(pr.base || 'base')}</span>` : ''}</div>
-${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="c4-facts"><div><dt>CI</dt><dd>${pill(pr.ci || 'No checks reported', pr.ci === 'Passed' ? 'ok' : pr.ci === 'Failed' || pr.ci === 'Errored' ? 'bad' : 'skip')}</dd></div><div><dt>Merge</dt><dd>${pill(pr.merge || 'Open', pr.state === 'merged' ? 'ok' : pr.merge === 'Has conflicts' ? 'bad' : 'skip')}</dd></div><div><dt>Checked</dt><dd>${esc(age(pr.checked_at))}</dd></div></dl>`}</div>`;
+${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="c4-facts"><div><dt>CI</dt><dd>${pill(pr.ci || 'No checks reported', pr.ci === 'Passed' ? 'ok' : pr.ci === 'Failed' || pr.ci === 'Errored' ? 'bad' : 'skip')}</dd></div><div><dt>Merge</dt><dd>${pill(pr.merge || 'Open', pr.state === 'merged' ? 'ok' : pr.merge === 'Has conflicts' ? 'bad' : 'skip')}</dd></div><div><dt>Deploy</dt><dd>${pill(deployWord, deployKind)}${deployNote ? ` <span class="c4-hint">${deployNote}</span>` : ''}</dd></div><div><dt>Checked</dt><dd>${esc(age(pr.checked_at))}</dd></div></dl>`}</div>`;
};
};
// ── The conductor (C9b) ─────────────────────────────────────────────────────────────────────────────────────
// ── The conductor (C9b) ─────────────────────────────────────────────────────────────────────────────────────
@@ -488,6 +508,48 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
@@ -488,6 +508,48 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
return res.redirect(303, `/approvals?${out ? 'ok' : 'error'}=${encodeURIComponent(out ? (decision === 'allow' ? 'Allowed' : 'Denied') : 'that approval is gone')}`);
return res.redirect(303, `/approvals?${out ? 'ok' : 'error'}=${encodeURIComponent(out ? (decision === 'allow' ? 'Allowed' : 'Denied') : 'that approval is gone')}`);
});
});
+ // ── Agents: the fleet each machine reports, then the person's connections (keys live in OpenVibe.AI) ────────
+ /**
+ * One calm row per agent: its state, a meter for each limit window and when it frees. The fleet comes from the
+ * machines' heartbeats through Bot (`fleet.refresh` writes it, `fleet.agents` reads it), so the page keeps the last
+ * reading when Bot or a machine does not answer.
+ */
+ const fleetCard = async (req, res = null) => {
+ let problem = null;
+ let refreshed = null;
+ if (fleet) {
+ try {
+ refreshed = await sso.asViewer(req, res, (token) => fleet.refresh(req.viewer.subject, token));
+ if (refreshed && refreshed.problem) problem = refreshed.problem;
+ } catch (e) {
+ problem = { detail: String((e && e.message) || 'the fleet could not be read').slice(0, 240) };
+ }
+ }
+ const rows = fleet ? await fleet.agents(req.viewer.subject, harnesses).catch(() => []) : [];
+ const latest = fleet ? await fleet.latest(req.viewer.subject).catch(() => ({ at: null })) : { at: null };
+ const meter = (w) => {
+ const pct = Math.max(0, Math.min(100, Number(w.pct) || 0));
+ const full = Number(w.pct) >= 95;
+ const free = whenUtc(w.resets_at);
+ const used = w.used != null && w.limit != null ? `${w.used} of ${w.limit}` : (w.pct != null ? `${Math.round(Number(w.pct))}%` : '');
+ return `<div class="c4-meter"><span class="c4-meter-name">${esc(w.name || 'window')}</span><span class="c4-meter-track" role="img" aria-label="${esc(`${w.name || 'window'}: ${Math.round(pct)}% used${full ? ', full' : ''}`)}"><span class="c4-meter-fill${full ? ' is-full' : ''}" style="width:${pct}%"></span></span><span class="c4-meter-note">${used ? `<b>${esc(used)}</b>` : ''}${used && free ? ' · ' : ''}${esc(free || (used ? '' : 'no reading'))}</span></div>`;
+ };
+ const list = rows.map((row) => {
+ const [label, kind] = FLEET_STATE[row.state] || FLEET_STATE.unknown;
+ const where = (row.machines || []).map((m) => m.name || m.machine).filter(Boolean).slice(0, 3).join(', ');
+ const windows = row.windows && row.windows.length ? row.windows.map(meter).join('') : '';
+ // A pay-per-token agent says what is left of the money, not of a plan window.
+ const credit = row.balance_usd == null ? '' : ` · $${Number(row.balance_usd).toFixed(2)} left`;
+ return `<li class="c4-row c4-fleet-row"><span class="c4-row-dot${row.state === 'ok' ? ' c4-dot-ok' : row.state === 'login' || row.state === 'limited' || row.state === 'empty' ? ' c4-dot-bad' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(row.name)}</span><span class="c4-row-note">${esc(where || 'on your machine')}${row.signed_in ? '' : ' · not signed in'}${esc(credit)}${windows ? '' : ' · no limit reported'}</span>${pill(label, kind)}${windows ? `<div class="c4-fleet-windows">${windows}</div>` : ''}</li>`;
+ }).join('');
+ const at = latest && latest.at ? `Reported ${age(latest.at) === '0m ago' ? 'just now' : age(latest.at)}` : '';
+ return `<section class="c4-card" id="fleet"><div class="c4-card-head"><h2>Your fleet</h2><span class="c4-muted">${esc(at)}</span></div>
+<p class="c4-sub">What each agent on your machines has left. A machine reports it with its heartbeat; no key or account name leaves it.</p>
+${problem ? note(`The fleet could not be refreshed: ${esc(problem.detail || 'Bot did not answer')}. ${latest && latest.at ? 'The last reading is below.' : ''}`, 'bad') : ''}
+${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : `<div class="c4-emptyline">No machine has reported its agents yet. <a href="/machines">Pair a machine</a>, and its agents appear here while it is connected.</div>`}
+</section>`;
+ };
+
// ── Agents: the person's connections (keys live in OpenVibe.AI) ────────────────────────────────────────────
// ── Agents: the person's connections (keys live in OpenVibe.AI) ────────────────────────────────────────────
const connectionsCard = async (req) => {
const connectionsCard = async (req) => {
let list = { keys: [], plans: [] };
let list = { keys: [], plans: [] };
@@ -515,7 +577,7 @@ ${rows || plans ? `<ul class="c4-rows">${rows}${plans}</ul>` : `<div class="c4-e
@@ -515,7 +577,7 @@ ${rows || plans ? `<ul class="c4-rows">${rows}${plans}</ul>` : `<div class="c4-e
};
};
r.get('/agents', async (req, res) => {
r.get('/agents', async (req, res) => {
- const content = `${header('Agents', 'The agents your tasks can run, and the keys behind them.')}${viewer(req) ? `${flash(req)}${await connectionsCard(req)}` : signIn(req)}`;
+ const content = `${header('Agents', 'The agents your tasks can run, what they have left, and the keys behind them.')}${viewer(req) ? `${flash(req)}${await fleetCard(req, res)}${await connectionsCard(req)}` : signIn(req)}`;
page(req, res, 'Agents', wrap(content));
page(req, res, 'Agents', wrap(content));
});
});
r.post('/agents/connections', form, async (req, res) => {
r.post('/agents/connections', form, async (req, res) => {
@@ -21,8 +21,9 @@
@@ -21,8 +21,9 @@
* `openvibe-codes task` reads from ~/.config/openvibe/token. Always the person's own rows; another person's task is 404.
* `openvibe-codes task` reads from ~/.config/openvibe/token. Always the person's own rows; another person's task is 404.
*
*
* The rule: the conductor may start sub-tasks on any agent except an Anthropic one. codes.task_start refuses
* The rule: the conductor may start sub-tasks on any agent except an Anthropic one. codes.task_start refuses
- * claude-code / claude-other, an Anthropic model, or no agent at all (which the router could send to Claude Code): the
- * conductor is the only Anthropic session in the system.
+ * claude-code, claude-other and an Anthropic model: the conductor is the only Anthropic session in the system. A
+ * sub-task with no agent named is `auto` (Codes C9c): the router reads the fleet and picks the agent with room, never
+ * a fixed one and never an Anthropic one.
*/
*/
const express = require('express');
const express = require('express');
@@ -39,7 +40,7 @@ const ANTHROPIC_MODEL = /(^|[^a-z])(claude|opus|sonnet|haiku|anthropic)([^a-z]|$
@@ -39,7 +40,7 @@ const ANTHROPIC_MODEL = /(^|[^a-z])(claude|opus|sonnet|haiku|anthropic)([^a-z]|$
const WAIT_MS = 20_000;
const WAIT_MS = 20_000;
const WAIT_POLL_MS = 750;
const WAIT_POLL_MS = 750;
-function createConductorApi({ config, store, keys, harnesses, tasks, conductor, approvals, machines = null, actorLimits, log = console }) {
+function createConductorApi({ config, store, keys, harnesses, tasks, conductor, approvals, machines = null, fleet = null, actorLimits, log = console }) {
const r = asyncRouter();
const r = asyncRouter();
const db = store.db;
const db = store.db;
const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
@@ -110,6 +111,26 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
@@ -110,6 +111,26 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
} catch { return null; }
} catch { return null; }
}
}
+ /**
+ * The machine a conductor runs on, pinned (Codes C9c): its OpenVibe.Bot device id (`dev_…`), which is what Run
+ * places a job on. Network knows the machine by its principal (`nod_…`); Bot's list names both. A machine Codes
+ * cannot find there, or a Bot that does not answer, is a problem — a conductor must never fall back to running
+ * wherever Run likes, because its folder and its memory are on the chosen machine.
+ */
+ async function pinFor(req, machine) {
+ if (!fleet) return { problem: { status: 503, code: 'conductor.machine_unpinned', detail: 'Codes cannot pin a conductor to a machine here' } };
+ let hit;
+ try {
+ hit = await fleet.deviceFor(req.person.token, machine);
+ } catch (e) {
+ log.warn(`[Codes] conductor: ${machine} could not be pinned: ${String(e && e.message).slice(0, 200)}`);
+ return { problem: { status: 503, code: 'conductor.machine_unpinned', detail: 'OpenVibe.Bot did not answer, so Codes cannot pin the conductor to that machine; a conductor runs where its folder is, never wherever' } };
+ }
+ if (!hit) return { problem: { status: 422, code: 'conductor.machine_invalid', detail: 'that machine is not on openvibe.bot/machines: add it there, so a conductor can run where its folder is' } };
+ if (!hit.online) return { problem: { status: 409, code: 'conductor.machine_offline', detail: `${hit.name || 'that machine'} is offline: a conductor starts on the machine its folder is on` } };
+ return { device: hit.device };
+ }
+
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.post('/conductor', person, actorLimits.budget('codes.task.start'), small, async (req, res) => {
r.post('/conductor', person, actorLimits.budget('codes.task.start'), small, async (req, res) => {
@@ -119,11 +140,13 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
@@ -119,11 +140,13 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
const machine = typeof b.machine === 'string' && b.machine ? b.machine : null;
const machine = typeof b.machine === 'string' && b.machine ? b.machine : null;
const bad = await machineProblem(req, machine);
const bad = await machineProblem(req, machine);
if (bad) return problem(req, res, 422, 'conductor.machine_invalid', bad);
if (bad) return problem(req, res, 422, 'conductor.machine_invalid', bad);
+ const pin = machine ? await pinFor(req, machine) : { device: null };
+ if (pin.problem) return problem(req, res, pin.problem.status, pin.problem.code, pin.problem.detail);
const project = await projectFor(req);
const project = await projectFor(req);
if (!project) return problem(req, res, 409, 'codes.no_project', 'start one task at ' + config.baseUrl + '/run first: a conductor runs in your project, on your machine');
if (!project) return problem(req, res, 409, 'codes.no_project', 'start one task at ' + config.baseUrl + '/run first: a conductor runs in your project, on your machine');
try {
try {
const made = await conductor.create({
const made = await conductor.create({
- subject: req.person.subject, project, folder, machine,
+ subject: req.person.subject, project, folder, machine, pin: pin.device,
model: typeof b.model === 'string' && b.model ? b.model : 'opus',
model: typeof b.model === 'string' && b.model ? b.model : 'opus',
safety: typeof b.safety === 'string' && b.safety ? b.safety : 'ask',
safety: typeof b.safety === 'string' && b.safety ? b.safety : 'ask',
note: typeof b.note === 'string' ? b.note : null,
note: typeof b.note === 'string' ? b.note : null,
@@ -169,8 +192,7 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
@@ -169,8 +192,7 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
/**
/**
* A sub-task on another harness. The rule: never an Anthropic one — that is the conductor's session alone. An
* A sub-task on another harness. The rule: never an Anthropic one — that is the conductor's session alone. An
- * unnamed agent is filled in with a non-Anthropic default, because the router would otherwise be free to pick
- * Claude Code.
+ * unnamed agent is `auto`: the router picks among the agents the fleet says have room, never a fixed one.
*/
*/
r.post('/conductor/:id/tasks', person, actorLimits.budget('codes.task.start'), small, async (req, res) => {
r.post('/conductor/:id/tasks', person, actorLimits.budget('codes.task.start'), small, async (req, res) => {
if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');
if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');
@@ -179,23 +201,23 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
@@ -179,23 +201,23 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
const b = req.body && typeof req.body === 'object' ? req.body : {};
const b = req.body && typeof req.body === 'object' ? req.body : {};
const prompt = typeof b.prompt === 'string' ? b.prompt.trim() : '';
const prompt = typeof b.prompt === 'string' ? b.prompt.trim() : '';
if (!prompt) return problem(req, res, 422, 'codes.task_invalid', 'a sub-task needs a prompt');
if (!prompt) return problem(req, res, 422, 'codes.task_invalid', 'a sub-task needs a prompt');
- const defaultAgent = (config.conductor && config.conductor.subagentAgent) || 'codex';
- const wanted = typeof b.agent === 'string' && b.agent ? b.agent : defaultAgent;
+ const named = typeof b.agent === 'string' && b.agent.trim() ? b.agent.trim() : null;
+ const wanted = named && named !== 'auto' ? named : null;
const model = typeof b.model === 'string' && b.model ? b.model : null;
const model = typeof b.model === 'string' && b.model ? b.model : null;
- if (ANTHROPIC_AGENTS.has(wanted) || ANTHROPIC_MODEL.test(wanted) || (model && ANTHROPIC_MODEL.test(model))) {
+ if ((wanted && (ANTHROPIC_AGENTS.has(wanted) || ANTHROPIC_MODEL.test(wanted))) || (model && ANTHROPIC_MODEL.test(model))) {
return problem(req, res, 422, 'conductor.anthropic_subtask', 'the conductor may start sub-tasks on any agent except an Anthropic one: it is the only Anthropic session here');
return problem(req, res, 422, 'conductor.anthropic_subtask', 'the conductor may start sub-tasks on any agent except an Anthropic one: it is the only Anthropic session here');
}
}
const agent = wanted;
const agent = wanted;
- if (!harnesses.list().some((h) => h.id === agent)) return problem(req, res, 422, 'codes.agent_unknown', `agent is one of GET /api/v1/harnesses, and not an Anthropic one`);
+ if (agent && !harnesses.list().some((h) => h.id === agent)) return problem(req, res, 422, 'codes.agent_unknown', `agent is one of GET /api/v1/harnesses, and not an Anthropic one`);
if (typeof b.repo !== 'string' || !/^https:\/\//.test(b.repo)) return problem(req, res, 422, 'codes.task_invalid', 'a sub-task needs the repository it works in (https://…)');
if (typeof b.repo !== 'string' || !/^https:\/\//.test(b.repo)) return problem(req, res, 422, 'codes.task_invalid', 'a sub-task needs the repository it works in (https://…)');
let args;
let args;
try {
try {
- args = parseArgs(JSON.stringify({ repo: b.repo, ref: b.ref || null, prompt, agent, ...(model ? { model } : {}), permission: 'edit' }));
+ args = parseArgs(JSON.stringify({ repo: b.repo, ref: b.ref || null, prompt, ...(agent ? { agent } : {}), ...(model ? { model } : {}), permission: 'edit' }));
} catch (e) {
} catch (e) {
return problem(req, res, 422, 'codes.task_invalid', e.message);
return problem(req, res, 422, 'codes.task_invalid', e.message);
}
}
const task = await tasks.start({ subject: req.person.subject, project: row.project_id, args, via: 'conductor', key: null, actorRef: { conductor: { task_id: row.id } } });
const task = await tasks.start({ subject: req.person.subject, project: row.project_id, args, via: 'conductor', key: null, actorRef: { conductor: { task_id: row.id } } });
- log.info(`[Codes] conductor ${row.id} started sub-task ${task.id} on ${agent}`);
+ log.info(`[Codes] conductor ${row.id} started sub-task ${task.id} on ${agent || 'auto'}`);
return res.status(201).json({ task: { ...tasks.present(task), outcome: { state: task.outcome || null, note: task.outcome_note || null } } });
return res.status(201).json({ task: { ...tasks.present(task), outcome: { state: task.outcome || null, note: task.outcome_note || null } } });
});
});
@@ -18,9 +18,9 @@ const SERVICES_ORIGIN = 'https://openvibe.services';
@@ -18,9 +18,9 @@ const SERVICES_ORIGIN = 'https://openvibe.services';
const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/tools', '/manifests', '/staff'];
const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/tools', '/manifests', '/staff'];
const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];
const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];
// The console's API: everything under /api/v1 but the agent catalog, the coding tasks (codes.task.*), the person's
// The console's API: everything under /api/v1 but the agent catalog, the coding tasks (codes.task.*), the person's
-// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3) and the conductor
-// with its approvals inbox (Codes C9b).
-const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me|connections|conductor|approvals)(\/|$)/;
+// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor
+// with its approvals inbox (Codes C9b) and the fleet their machines report (Codes C9c).
+const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me|connections|conductor|approvals|fleet)(\/|$)/;
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
@@ -39,7 +39,7 @@ const { expandMatrix } = require('../domain/bench');
@@ -39,7 +39,7 @@ const { expandMatrix } = require('../domain/bench');
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
-function createTasksApi({ config, store, keys, run, harnesses, actorLimits, tasks = null, log = console }) {
+function createTasksApi({ config, store, keys, run, harnesses, actorLimits, tasks = null, fleet = null, log = console }) {
const r = asyncRouter();
const r = asyncRouter();
const db = store.db;
const db = store.db;
const taskStore = tasks || createRunTasks({ store, run, config, log });
const taskStore = tasks || createRunTasks({ store, run, config, log });
@@ -135,6 +135,34 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
@@ -135,6 +135,34 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
};
};
};
};
+ /**
+ * The fleet (Codes C9c): each of the person's machines' agents and limit windows, read from OpenVibe.Bot with the
+ * person's own token and kept here. A signed-in visitor or their own access token; never a service acting for
+ * someone (a machine's windows are the person's own account).
+ */
+ r.get('/fleet', actorLimits.reads('codes.read'), async (req, res) => {
+ let subject = null;
+ let token = null;
+ if (signedIn(req)) { subject = req.viewer.subject; token = req.viewer.token || null; }
+ else {
+ const header = String(req.headers.authorization || '');
+ if (header.startsWith('Bearer ') && !principalToken(header.slice(7).trim())) {
+ token = header.slice(7).trim();
+ subject = await personOf(token);
+ }
+ }
+ if (!subject) return problem(req, res, 401, 'token.required', 'sign in, or send your own Network access token, to read your fleet');
+ if (!fleet) return res.json({ ok: false, machines: [], agents: [], problem: { status: 503, detail: 'the fleet is not configured here' } });
+ let out;
+ try { out = await fleet.refresh(subject, token); } catch (e) {
+ if (e.status === 401) return problem(req, res, 401, 'token.invalid', 'the session expired: sign in again');
+ out = { ok: false, problem: { status: e.status || 503, detail: String(e.message || 'the fleet could not be read').slice(0, 300) } };
+ }
+ const agents = await fleet.agents(subject, harnesses);
+ const latest = await fleet.latest(subject);
+ return res.json({ ok: out.ok !== false, machines: latest.machines, agents, at: latest.at, ...(out.problem ? { problem: out.problem } : {}) });
+ });
+
r.post('/tasks', guardAny('codes.task.start'), actorLimits.budget('codes.task.start'), json, async (req, res) => {
r.post('/tasks', guardAny('codes.task.start'), actorLimits.budget('codes.task.start'), json, async (req, res) => {
const b = req.body;
const b = req.body;
// Actor back-reference: the contract does not carry these yet, so they are stripped before validation and stored
// Actor back-reference: the contract does not carry these yet, so they are stripped before validation and stored
@@ -55,6 +55,9 @@ async function startNetworkStub() {
@@ -55,6 +55,9 @@ async function startNetworkStub() {
await db.exec("INSERT INTO codes_outbox (event_id, topic, subject, payload) VALUES ($1, 'codes.task.updated', $2, '{}'::jsonb)", [`evt_${ids.ulid()}`, subject]);
await db.exec("INSERT INTO codes_outbox (event_id, topic, subject, payload) VALUES ($1, 'codes.task.updated', $2, '{}'::jsonb)", [`evt_${ids.ulid()}`, subject]);
await db.exec("INSERT INTO telemetry_samples (at, service, operation, subject) VALUES (now(), 'codes', 'task.start', $1)", [subject]);
await db.exec("INSERT INTO telemetry_samples (at, service, operation, subject) VALUES (now(), 'codes', 'task.start', $1)", [subject]);
await db.exec("INSERT INTO codes_events_inbox (event_id, event_type, task_id) VALUES ($1, 'run.job.finished', $2)", [`evt_${ids.ulid()}`, taskId]);
await db.exec("INSERT INTO codes_events_inbox (event_id, event_type, task_id) VALUES ($1, 'run.job.finished', $2)", [`evt_${ids.ulid()}`, taskId]);
+ await db.exec("INSERT INTO codes_notices (task_id, kind) VALUES ($1, 'done')", [taskId]);
+ await db.exec(`INSERT INTO codes_machine_fleet (subject, machine, name, agent, installed, signed_in, windows, online, reported_at)
+ VALUES ($1, 'dev_laptop', 'laptop', 'codex', true, true, '[]'::jsonb, true, now())`, [subject]);
}
}
const deliver = async (event, { secret = SECRET } = {}) => {
const deliver = async (event, { secret = SECRET } = {}) => {
const body = JSON.stringify({ event, seq: 1 });
const body = JSON.stringify({ event, seq: 1 });
@@ -103,6 +106,10 @@ async function startNetworkStub() {
@@ -103,6 +106,10 @@ async function startNetworkStub() {
assert.ok(telemetry, 'telemetry.json is in the export (the SDK covers the table)');
assert.ok(telemetry, 'telemetry.json is in the export (the SDK covers the table)');
assert.strictEqual(telemetry.content.length, 1, 'Ada’s sample only');
assert.strictEqual(telemetry.content.length, 1, 'Ada’s sample only');
assert.deepStrictEqual(Object.keys(telemetry.content[0]).sort(), ['at', 'latency_ms', 'operation', 'resource', 'service', 'status']);
assert.deepStrictEqual(Object.keys(telemetry.content[0]).sort(), ['at', 'latency_ms', 'operation', 'resource', 'service', 'status']);
+ const fleet = part.body.files.find((x) => x.name === 'fleet.json');
+ assert.ok(fleet, 'fleet.json is in the export');
+ assert.strictEqual(fleet.content.length, 1, 'Ada’s fleet only');
+ assert.strictEqual(fleet.content[0].agent, 'codex');
});
});
await check('a deletion erases them, keeps everyone else’s, and confirms once', async () => {
await check('a deletion erases them, keeps everyone else’s, and confirms once', async () => {
@@ -117,6 +124,7 @@ async function startNetworkStub() {
@@ -117,6 +124,7 @@ async function startNetworkStub() {
assert.strictEqual(confirmations[0].body.erased.run_tasks, 1);
assert.strictEqual(confirmations[0].body.erased.run_tasks, 1);
for (const [table, key, who] of [
for (const [table, key, who] of [
['codes_attempts', 'task_id', [boTask]], ['codes_task_proof', 'task_id', [boTask]], ['codes_events_inbox', 'task_id', [boTask]],
['codes_attempts', 'task_id', [boTask]], ['codes_task_proof', 'task_id', [boTask]], ['codes_events_inbox', 'task_id', [boTask]],
+ ['codes_notices', 'task_id', [boTask]], ['codes_machine_fleet', 'subject', [bo]],
['codes_run_tokens', 'subject', [bo]], ['codes_user_settings', 'subject', [bo]], ['codes_outbox', 'subject', [bo]],
['codes_run_tokens', 'subject', [bo]], ['codes_user_settings', 'subject', [bo]], ['codes_outbox', 'subject', [bo]],
]) {
]) {
const left = (await db.many(`SELECT ${key} FROM ${table}`)).map((r) => r[key]);
const left = (await db.many(`SELECT ${key} FROM ${table}`)).map((r) => r[key]);
@@ -76,6 +76,8 @@ function mcpChild(env) {
@@ -76,6 +76,8 @@ function mcpChild(env) {
const db = t.ctx.store.db;
const db = t.ctx.store.db;
const ada = t.network.addUser('ada');
const ada = t.network.addUser('ada');
t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });
t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });
+ // The machine, as OpenVibe.Bot answers it: the conductor is pinned to its device id, so Run never moves it.
+ t.bot.addMachine({ id: 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2', node_id: NODE, name: 'ada-laptop', online: true });
const created = await t.get('/api/v1/conductor', { as: ada, json: { folder: '/home/ada/openvibe', machine: NODE, safety: 'ask' } });
const created = await t.get('/api/v1/conductor', { as: ada, json: { folder: '/home/ada/openvibe', machine: NODE, safety: 'ask' } });
assert.strictEqual(created.status, 201, created.text);
assert.strictEqual(created.status, 201, created.text);
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+
+/**
+ * The Agents page shows the fleet (Codes C9c): one calm row per agent, what each machine reports with its heartbeat —
+ * a meter per limit window, and when it frees.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const ada = t.network.addUser('ada');
+ const RESET = '2026-11-04T00:00:00Z';
+ // The machine, as OpenVibe.Bot answers it: the page reads Bot and keeps the latest reading per machine.
+ t.bot.addMachine({
+ id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet_at: new Date().toISOString(),
+ fleet: [
+ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', used: 96, limit: 100, pct: 96, resets_at: RESET }] },
+ { agent: 'minimax-code', installed: true, signed_in: true, windows: [] },
+ { agent: 'opencode', installed: true, signed_in: false, windows: [] },
+ ],
+ });
+ await t.ctx.store.db.exec(
+ `INSERT INTO codes_machine_fleet (subject, machine, node_id, name, agent, installed, signed_in, windows, online, reported_at, updated_at)
+ VALUES ($1,'dev_ada_laptop','nod_ada','ada-laptop','codex',true,true,$2::jsonb,true,now(),now()),
+ ($1,'dev_ada_laptop','nod_ada','ada-laptop','minimax-code',true,true,'[]'::jsonb,true,now(),now()),
+ ($1,'dev_ada_laptop','nod_ada','ada-laptop','opencode',true,false,'[]'::jsonb,true,now(),now())`,
+ [ada.subject, JSON.stringify([{ name: 'week', used: 96, limit: 100, pct: 96, resets_at: RESET }])],
+ );
+
+ try {
+ await check('the Agents page lists each agent of the fleet with a meter and when its window frees', async () => {
+ const r = await t.get('/agents', { as: ada });
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('Your fleet'), 'the fleet card');
+ assert.ok(r.text.includes('c4-fleet-row'), 'a row per agent');
+ assert.ok(r.text.includes('c4-meter'), 'a meter per window');
+ assert.ok(r.text.includes('c4-meter-fill is-full'), 'a full window is drawn full');
+ assert.ok(r.text.includes('week'), 'the window is named');
+ assert.ok(/frees (now|in \d+ (min|h|d)) · Wed 00:00 UTC/.test(r.text), 'when it frees: how soon, and the time in UTC');
+ assert.ok(r.text.includes('<b>96 of 100</b>'), 'how much is used');
+ assert.ok(r.text.includes('ada-laptop · no limit reported'), 'an agent without a window says so beside its machine, not as an empty meter');
+ assert.ok(!r.text.includes('No limit window'), 'no empty meter row');
+ assert.ok(r.text.includes('ada-laptop'), 'the machine that reported it');
+ });
+
+ await check('each agent gets the state a person reads: full, has room, or sign in', async () => {
+ const r = await t.get('/agents', { as: ada });
+ assert.ok(r.text.includes('>Full<'), 'Codex at 96% is full');
+ assert.ok(r.text.includes('>Has room<'), 'MiniMax has room');
+ assert.ok(r.text.includes('>Sign in<'), 'OpenCode is not signed in');
+ assert.ok(r.text.includes('not signed in'));
+ });
+
+ await check('with nothing reported the page says so and links to pairing a machine', async () => {
+ const bo = t.network.addUser('bo');
+ t.bot.state.machines.length = 0; // no machine of bo's is connected
+ const r = await t.get('/agents', { as: bo });
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('No machine has reported its agents yet'));
+ assert.ok(r.text.includes('/machines'));
+ });
+ } catch (e) {
+ console.error(e);
+ process.exitCode = 1;
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
+'use strict';
+
+/**
+ * Codes C9c, end to end: a task with no agent named is `auto`, and the router reads the fleet the machines reported.
+ * Today (2026-10-30) Codex's week is spent, OpenCode's month is spent and Command Code is at 96% of its week, so auto
+ * must land on MiniMax or DeepSeek — and never on Claude. A finished or unfinished task then tells the person once.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N3';
+const RESET_WEEK = '2026-11-04T00:00:00Z';
+const RESET_MONTH = '2026-11-02T00:00:00Z';
+
+(async () => {
+ const submits = [];
+ let counter = 0;
+ const run = {
+ enabled: true,
+ async submit(o) { counter++; submits.push(o); return { id: `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J${String(counter).padStart(2, '0')}`, state: 'queued' }; },
+ async read(jobId) { return { id: jobId, state: 'queued' }; },
+ async cancel() { return { state: 'cancelled' }; },
+ async streamUrl() { throw new Error('not used'); },
+ };
+ const pushed = [];
+ const notify = { async push(body) { pushed.push(body); return { sent: true }; } };
+ // DeepSeek's balance is read the way the portal reads it (engine/balance.js): $0.02 today, too little for a job.
+ const t = await boot({
+ run, notify,
+ balance: async () => ({ deepseek: { usd: 0.02, at: new Date().toISOString() } }),
+ // The fleet is read fresh every time here, so a changed reading is the one the router sees.
+ env: { CODES_FLEET_TTL_MS: '0' },
+ });
+ const db = t.ctx.store.db;
+ const ada = t.network.addUser('ada');
+ t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });
+
+ /** The fleet Ada's machines reported with their last heartbeats. */
+ const window = (name, pct, resetsAt) => JSON.stringify([{ name, pct, resets_at: resetsAt }]);
+ // The machine, as OpenVibe.Bot answers it: the device id Run pins a job to, the Network id the machine is known
+ // by, and the fleet its Node last reported with its heartbeat.
+ t.bot.addMachine({
+ id: 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1', node_id: NODE, name: 'ada-laptop', online: true, fleet_at: new Date().toISOString(),
+ fleet: [
+ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET_WEEK }] },
+ { agent: 'opencode', installed: true, signed_in: true, windows: [{ name: 'month', pct: 100, resets_at: RESET_MONTH }] },
+ { agent: 'command-code', installed: true, signed_in: true, windows: [{ name: 'week', pct: 96, resets_at: RESET_WEEK }] },
+ { agent: 'minimax-code', installed: true, signed_in: true, windows: [] },
+ { agent: 'deepseek', installed: true, signed_in: true, windows: [] },
+ ],
+ });
+ await db.exec(
+ `INSERT INTO codes_machine_fleet (subject, machine, node_id, name, agent, installed, signed_in, windows, online, reported_at, updated_at)
+ VALUES ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','codex',true,true,$2::jsonb,true,now(),now()),
+ ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','opencode',true,true,$3::jsonb,true,now(),now()),
+ ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','command-code',true,true,$4::jsonb,true,now(),now()),
+ ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','minimax-code',true,true,'[]'::jsonb,true,now(),now()),
+ ($1,'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1','nod_ada','ada-laptop','deepseek',true,true,'[]'::jsonb,true,now(),now())`,
+ [ada.subject, window('week', 100, RESET_WEEK), window('month', 100, RESET_MONTH), window('week', 96, RESET_WEEK)],
+ );
+
+ const start = async (prompt) => {
+ const r = await t.get('/api/v1/me/tasks', { as: ada, json: { repo: 'https://github.com/octocat/Hello-World', prompt, idempotency_key: `c9c_${Math.random().toString(36).slice(2, 10)}` } });
+ assert.strictEqual(r.status, 201, r.text);
+ return r.json().task;
+ };
+ const endAttempt = async (taskId, { state, result = null }) => {
+ const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [taskId]);
+ const updated = await t.ctx.tasks.applyEvent(row, { state, job: { id: row.job_id, state, result, error: null }, error: null });
+ await t.ctx.tasks.afterEnd(updated.id);
+ };
+
+ try {
+ await check('auto: today\'s fleet never starts a task on Codex, OpenCode or Command Code', async () => {
+ const hard = await start('Find the root cause of the race condition in the payment webhook and prove it with a test.');
+ assert.strictEqual(submits.at(-1).args.agent, 'minimax-code', 'Codex is spent, so MiniMax takes the hard task');
+ assert.strictEqual(hard.agent, 'minimax-code');
+ const basic = await start('Rename the variable in the one file and reformat it.');
+ assert.strictEqual(submits.at(-1).args.agent, 'minimax-code', 'OpenCode is spent and Command Code is at 96%');
+ assert.strictEqual(basic.agent, 'minimax-code');
+ for (const s of submits) assert.ok(!/claude/.test(String(s.args.agent)), 'never an Anthropic agent');
+ });
+
+ await check('a sub-task the conductor starts without an agent is auto too', async () => {
+ const created = await t.get('/api/v1/conductor', { as: ada, json: { folder: '/home/ada/openvibe', machine: NODE } });
+ assert.strictEqual(created.status, 201, created.text);
+ const before = submits.length;
+ const r = await t.get(`/api/v1/conductor/${created.json().task.id}/tasks`, { as: ada, json: { prompt: 'Fix the parser and add a test.', repo: 'https://github.com/octocat/Hello-World' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(submits.length, before + 1);
+ assert.strictEqual(submits.at(-1).args.agent, 'minimax-code', 'the router picked, not a fixed codex');
+ assert.strictEqual(r.json().task.agent, 'minimax-code');
+ });
+
+ await check('a finished task tells the person once, and a redelivered ending does not tell them again', async () => {
+ const task = await start('Add the endpoint and its tests.');
+ await endAttempt(task.id, { state: 'succeeded', result: { ok: true, summary: 'It added the endpoint and its tests.', files: [], log: [{ t: 'done', ok: true, text: 'done' }] } });
+ const told = pushed.filter((p) => p.title === 'Your task is done' && p.url.endsWith(`/tasks/${task.id}`));
+ assert.strictEqual(told.length, 1, JSON.stringify(pushed));
+ assert.match(told[0].message, /endpoint/);
+ await t.ctx.tasks.afterEnd(task.id);
+ assert.strictEqual(pushed.filter((p) => p.url.endsWith(`/tasks/${task.id}`)).length, 1, 'one notice per task and state');
+ });
+
+ await check('a task that needs a person says so, high priority', async () => {
+ const task = await start('Rename a variable in the one file.');
+ // The first failure is a second opinion on another agent; when that one fails too, the task needs a person.
+ await endAttempt(task.id, { state: 'failed', result: { ok: false, summary: 'I could not finish it', files: [], log: [{ t: 'done', ok: false, text: 'I could not finish it' }] } });
+ await endAttempt(task.id, { state: 'failed', result: { ok: false, summary: 'I could not finish it either', files: [], log: [{ t: 'done', ok: false, text: 'I could not finish it either' }] } });
+ const row = await db.one('SELECT outcome, outcome_note FROM run_tasks WHERE id = $1', [task.id]);
+ assert.strictEqual(row.outcome, 'unfinished', row.outcome_note);
+ const told = pushed.filter((p) => p.title === 'Your task needs you' && p.url.endsWith(`/tasks/${task.id}`));
+ assert.strictEqual(told.length, 1, JSON.stringify(pushed));
+ assert.strictEqual(told[0].priority, 'high');
+ });
+
+ await check('a task the person stopped tells them nothing', async () => {
+ const task = await start('Rename a variable in the one file now.');
+ await endAttempt(task.id, { state: 'cancelled' });
+ assert.strictEqual(pushed.filter((p) => p.url.endsWith(`/tasks/${task.id}`)).length, 0);
+ });
+
+ await check('a task that no agent may take is refused, and needs a person', async () => {
+ // Every flat plan is spent and DeepSeek has $0.02: nothing may take it, and the task says why.
+ t.bot.state.machines[0].fleet = [
+ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET_WEEK }] },
+ { agent: 'opencode', installed: true, signed_in: true, windows: [{ name: 'month', pct: 100, resets_at: RESET_MONTH }] },
+ { agent: 'command-code', installed: true, signed_in: true, windows: [{ name: 'week', pct: 96, resets_at: RESET_WEEK }] },
+ { agent: 'minimax-code', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET_WEEK }] },
+ { agent: 'deepseek', installed: true, signed_in: true, windows: [] },
+ ];
+ await t.get('/api/v1/fleet', { as: ada }); // the reading Bot now answers is the one Codes keeps
+ const before = submits.length;
+ const r = await t.get('/api/v1/me/tasks', { as: ada, json: { repo: 'https://github.com/octocat/Hello-World', prompt: 'Find the root cause of the race condition in the payment webhook.', idempotency_key: `c9c_${Math.random().toString(36).slice(2, 10)}` } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(submits.length, before, 'no job was started');
+ const row = await db.one('SELECT state, error, outcome FROM run_tasks WHERE id = $1', [r.json().task.id]);
+ assert.strictEqual(row.state, 'failed');
+ assert.match(row.error, /no agent has room/, row.error);
+ assert.match(row.error, /DeepSeek has \$0\.02 left/, row.error);
+ const told = pushed.filter((p) => p.title === 'Your task needs you' && p.url.endsWith(`/tasks/${r.json().task.id}`));
+ assert.strictEqual(told.length, 1, 'the person is told, once');
+ });
+
+ await check('/api/v1/fleet answers the stored fleet for the person', async () => {
+ // Ada's machine reports again: MiniMax has room, DeepSeek's balance is what it is.
+ t.bot.state.machines[0].fleet = [
+ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET_WEEK }] },
+ { agent: 'opencode', installed: true, signed_in: true, windows: [{ name: 'month', pct: 100, resets_at: RESET_MONTH }] },
+ { agent: 'command-code', installed: true, signed_in: true, windows: [{ name: 'week', pct: 96, resets_at: RESET_WEEK }] },
+ { agent: 'minimax-code', installed: true, signed_in: true, windows: [] },
+ { agent: 'deepseek', installed: true, signed_in: true, windows: [] },
+ ];
+ const r = await t.get('/api/v1/fleet', { as: ada });
+ assert.strictEqual(r.status, 200, r.text);
+ const body = r.json();
+ assert.ok(Array.isArray(body.machines) && body.machines.length === 1);
+ assert.ok(Array.isArray(body.agents));
+ const mm = body.agents.find((a) => a.agent === 'minimax-code');
+ assert.strictEqual(mm.state, 'ok');
+ const ds = body.agents.find((a) => a.agent === 'deepseek');
+ assert.strictEqual(ds.balance_usd, 0.02, 'the balance the portal reads, in the agent\'s own row');
+ assert.strictEqual(ds.state, 'empty', 'and an agent out of credit says so');
+ assert.ok(mm.machines.some((m) => m.machine === 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1'));
+ assert.strictEqual(body.problem, undefined, 'Bot answered, so there is no problem beside the fleet');
+ });
+ } catch (e) {
+ console.error(e);
+ process.exitCode = 1;
+ } finally {
+ await t.close();
+ }
+ done();
+})();
@@ -13,6 +13,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -13,6 +13,7 @@ const { boot, check, done } = require('./helpers/boot');
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
+const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
(async () => {
(async () => {
const calls = [];
const calls = [];
@@ -36,6 +37,10 @@ const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
@@ -36,6 +37,10 @@ const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
const ada = t.network.addUser('ada');
const ada = t.network.addUser('ada');
const bo = t.network.addUser('bo');
const bo = t.network.addUser('bo');
t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });
t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });
+ // The same machine on OpenVibe.Bot (Codes C9c): the device id is what Run places a job on, the node id what
+ // Network knows it by. A conductor's turns are pinned to it.
+ const mineBot = () => t.bot.addMachine({ id: DEVICE, node_id: NODE, name: 'ada-laptop', online: true });
+ mineBot();
const submits = () => calls.filter((c) => c[0] === 'submit');
const submits = () => calls.filter((c) => c[0] === 'submit');
const get = (path, as = ada) => t.get(path, { as });
const get = (path, as = ada) => t.get(path, { as });
@@ -91,6 +96,8 @@ const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
@@ -91,6 +96,8 @@ const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
assert.strictEqual(submit.args.mcp, true);
assert.strictEqual(submit.args.mcp, true);
assert.strictEqual(submit.args.resume, undefined, 'the first turn starts the session');
assert.strictEqual(submit.args.resume, undefined, 'the first turn starts the session');
assert.ok(submit.limits && submit.limits.wall_ms >= 60_000, 'a turn gets a wall time');
assert.ok(submit.limits && submit.limits.wall_ms >= 60_000, 'a turn gets a wall time');
+ assert.strictEqual(submit.node, DEVICE, 'the turn is pinned to the chosen machine, by its OpenVibe.Bot device id');
+ assert.strictEqual(row.pin, DEVICE, 'the pin is kept on the conductor');
});
});
await check('a message waits while a turn runs, then is the next turn of the same session', async () => {
await check('a message waits while a turn runs, then is the next turn of the same session', async () => {
@@ -111,6 +118,7 @@ const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
@@ -111,6 +118,7 @@ const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
const submit = submits().at(-1)[1];
const submit = submits().at(-1)[1];
assert.strictEqual(submit.args.resume, 'sess_abc', 'the next turn resumes the conversation');
assert.strictEqual(submit.args.resume, 'sess_abc', 'the next turn resumes the conversation');
assert.strictEqual(submit.args.prompt, 'now do the second piece');
assert.strictEqual(submit.args.prompt, 'now do the second piece');
+ assert.strictEqual(submit.node, DEVICE, 'every turn of the session is pinned to the same machine');
});
});
await check('a turn that ends with nothing queued leaves the conductor idle, not finished', async () => {
await check('a turn that ends with nothing queued leaves the conductor idle, not finished', async () => {
@@ -125,6 +133,32 @@ const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
@@ -125,6 +133,32 @@ const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
assert.strictEqual(view.messages.at(-1).result.summary, 'Everything is in order.');
assert.strictEqual(view.messages.at(-1).result.summary, 'Everything is in order.');
});
});
+ await check('a conductor is started only where its folder is: no such machine on Bot, away, or Bot itself away', async () => {
+ const other = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2';
+ t.network.addNode(ada.subject, { principal: other, node_id: 'node_ada_desktop', name: 'ada-desktop', status: 'active', last_seen_at: new Date().toISOString() });
+ const before = submits().length;
+ const unknown = await post('/api/v1/conductor', { folder: '/home/ada/openvibe', machine: other });
+ assert.strictEqual(unknown.status, 422, unknown.text);
+ assert.strictEqual(unknown.json().code, 'conductor.machine_invalid', 'a machine Codes cannot find on Bot is never a guess');
+ t.bot.addMachine({ id: 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2', node_id: other, name: 'ada-desktop', online: false });
+ const away = await post('/api/v1/conductor', { folder: '/home/ada/openvibe', machine: other });
+ assert.strictEqual(away.status, 409, away.text);
+ assert.strictEqual(away.json().code, 'conductor.machine_offline');
+ t.bot.setDown(true);
+ const down = await post('/api/v1/conductor', { folder: '/home/ada/openvibe', machine: NODE });
+ assert.strictEqual(down.status, 503, down.text);
+ assert.strictEqual(down.json().code, 'conductor.machine_unpinned');
+ assert.strictEqual(submits().length, before, 'no job was started for a conductor that could not be pinned');
+
+ // The pin is kept on the conductor: a later turn starts on the same machine without asking Bot again.
+ const res = await post(`/api/v1/conductor/${taskId}/messages`, { text: 'now the next piece' });
+ t.bot.setDown(false);
+ assert.strictEqual(res.status, 201, res.text);
+ assert.strictEqual(submits().at(-1)[1].node, DEVICE, 'the stored pin starts the turn, even with Bot away');
+ const ended = await endTurn(taskId, { session: 'sess_abc' });
+ assert.strictEqual(ended.state, 'succeeded', 'idle again');
+ });
+
await check('a failing turn is unfinished, and a message still carries on', async () => {
await check('a failing turn is unfinished, and a message still carries on', async () => {
const res = await post(`/api/v1/conductor/${taskId}/messages`, { text: 'try once more' });
const res = await post(`/api/v1/conductor/${taskId}/messages`, { text: 'try once more' });
assert.strictEqual(res.status, 201, res.text);
assert.strictEqual(res.status, 201, res.text);
@@ -146,8 +180,11 @@ const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
@@ -146,8 +180,11 @@ const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
assert.strictEqual(started.status, 201, started.text);
assert.strictEqual(started.status, 201, started.text);
const submit = submits().at(-1)[1];
const submit = submits().at(-1)[1];
assert.strictEqual(submits().length, before + 1);
assert.strictEqual(submits().length, before + 1);
- assert.strictEqual(submit.args.agent, 'codex', 'an unnamed agent is a non-Anthropic one');
+ // An unnamed agent is auto (Codes C9c): the router names one from the fleet, and never an Anthropic one.
+ assert.strictEqual(submit.args.agent, 'opencode', 'an unnamed agent is the one the router picked, from a non-Anthropic ladder');
+ assert.ok(!/claude/.test(String(submit.args.agent)), 'never an Anthropic agent');
assert.ok(!submit.args.push, 'a sub-task does not push');
assert.ok(!submit.args.push, 'a sub-task does not push');
+ assert.strictEqual(submit.node, undefined, 'a sub-task is placed by Run wherever it fits, not pinned');
const sub = started.json().task;
const sub = started.json().task;
assert.notStrictEqual(sub.id, taskId, 'a sub-task is its own task');
assert.notStrictEqual(sub.id, taskId, 'a sub-task is its own task');
const subRow = await rowOf(sub.id);
const subRow = await rowOf(sub.id);
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+
+/**
+ * What an agent's account has left, read the way the portal reads it (Codes C9c): the key is the person's own file,
+ * the call is DeepSeek's own balance endpoint, and the number is `balance_infos[0].total_balance`. Nothing else is read,
+ * and nothing else leaves: the key goes to DeepSeek and only the number comes back.
+ */
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { check, done } = require('./helpers/boot');
+const { readBalance, readBalances, keyOf, ACCOUNTS } = require('../engine/balance');
+
+/** A home with a DeepSeek key in it, and a fetch that answers like DeepSeek does. */
+function withKey(body, run) {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-balance-'));
+ fs.mkdirSync(path.join(home, '.config', 'deepseek'), { recursive: true });
+ fs.writeFileSync(path.join(home, '.config', 'deepseek', 'api_key'), body);
+ return run(home);
+}
+
+(async () => {
+ try {
+ await check('the key is the file the CLI keeps it in, and nothing else', () => {
+ withKey(' sk-secret\n', (home) => {
+ assert.equal(keyOf(ACCOUNTS.deepseek, { home }), 'sk-secret');
+ assert.equal(keyOf(ACCOUNTS.deepseek, { home: path.join(home, 'nowhere') }), null, 'no file, no key');
+ });
+ const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-balance-'));
+ fs.writeFileSync(path.join(empty, 'key'), ' \n');
+ assert.equal(keyOf(ACCOUNTS.deepseek, { home: empty }), null, 'a blank file is no key');
+ });
+
+ await check('the balance is what DeepSeek says it is', async () => {
+ const seen = [];
+ const fetchImpl = async (url, opts) => {
+ seen.push({ url, auth: opts.headers.Authorization });
+ return { ok: true, json: async () => ({ balance_infos: [{ total_balance: '0.02' }, { total_balance: '5.00' }] }) };
+ };
+ const out = await withKey('sk-secret', (home) => readBalance('deepseek', { home, fetchImpl }));
+ assert.equal(out.usd, 0.02, 'the first balance is the account\'s');
+ assert.ok(out.at, 'and when it was read');
+ assert.equal(seen.length, 1);
+ assert.equal(seen[0].url, 'https://api.deepseek.com/user/balance', 'the portal\'s own call');
+ assert.equal(seen[0].auth, 'Bearer sk-secret', 'with the person\'s own key');
+ });
+
+ await check('an answer that says nothing about a balance is unknown, never zero', async () => {
+ const noKey = await readBalance('deepseek', { home: os.tmpdir(), fetchImpl: async () => ({ ok: true, json: async () => ({}) }) });
+ assert.equal(noKey, null, 'no key, no balance');
+ await withKey('sk-secret', async (home) => {
+ const empty = await readBalance('deepseek', { home, fetchImpl: async () => ({ ok: true, json: async () => ({ balance_infos: [] }) }) });
+ assert.equal(empty, null, 'no balance_infos, no balance');
+ const broken = await readBalance('deepseek', { home, fetchImpl: async () => ({ ok: false, status: 402, json: async () => ({}) }) });
+ assert.equal(broken, null, 'a refused call is no balance');
+ const down = await readBalance('deepseek', { home, fetchImpl: async () => { throw new Error('unreachable'); } });
+ assert.equal(down, null, 'a call that does not answer is no balance');
+ const nan = await readBalance('deepseek', { home, fetchImpl: async () => ({ ok: true, json: async () => ({ balance_infos: [{ total_balance: 'nope' }] }) }) });
+ assert.equal(nan, null, 'a number that is not a number is no balance');
+ });
+ });
+
+ await check('every account is read, and the ones that cannot be are absent', async () => {
+ const out = await withKey('sk-secret', (home) => readBalances({
+ home,
+ fetchImpl: async () => ({ ok: true, json: async () => ({ balance_infos: [{ total_balance: 12.5 }] }) }),
+ }));
+ assert.equal(out.deepseek.usd, 12.5);
+ assert.deepEqual(Object.keys(out), ['deepseek'], 'an account with no key is absent, not zero');
+ });
+ } catch (e) {
+ console.error(e);
+ process.exitCode = 1;
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+
+/**
+ * The router on live quotas (Codes C9c). The fleet is what the person's machines reported with their heartbeats, and
+ * the router skips an agent whose window is full or that is not signed in — so `auto` never picks an agent that would
+ * stop mid-way. Today's state (2026-10-30) is in these tests: Codex's week is spent until 11-04, OpenCode's month
+ * until 11-02, and Command Code is at 96% of its week; auto must route to MiniMax or DeepSeek.
+ */
+const assert = require('node:assert/strict');
+const { check, done } = require('./helpers/boot');
+const router = require('../engine/router');
+
+const NOW = Date.parse('2026-10-30T12:00:00Z');
+const RESET_WEEK = '2026-11-04T00:00:00Z';
+const RESET_MONTH = '2026-11-02T00:00:00Z';
+
+const runner = (enabled = true) => ({ enabled, max: 4 });
+const cfg = { subagents: { cx: runner(), mm: runner(), cc: runner(), oc: runner(), dsh: runner(), claude: runner(false) } };
+const health = { cx: { state: 'ok' }, mm: { state: 'ok' }, cc: { state: 'ok' }, oc: { state: 'ok' }, dsh: { state: 'ok' } };
+const context = (fleet) => ({ cfg, health, counts: {}, models: {}, fleet });
+
+/** Today's state of the owner's plans, as the Nodes report it. */
+const TODAY = {
+ cx: { signed_in: true, installed: true, windows: [{ name: 'week', used: 100, limit: 100, pct: 100, resets_at: RESET_WEEK }] },
+ oc: { signed_in: true, installed: true, windows: [{ name: 'month', used: 100, limit: 100, pct: 100, resets_at: RESET_MONTH }] },
+ cc: { signed_in: true, installed: true, windows: [{ name: 'week', used: 96, limit: 100, pct: 96, resets_at: RESET_WEEK }] },
+ mm: { signed_in: true, installed: true, windows: [] },
+ dsh: { signed_in: true, installed: true, windows: [] },
+};
+
+const HARD = 'Root cause of the race condition in the payment webhook. Find the deadlock and write the test that proves it.';
+const BASIC = 'Rename the variable in the one file and reformat it.';
+
+(async () => {
+ try {
+ await check('a window at the full mark is spent, and one that has already reset is not', () => {
+ assert.equal(router.windowFull({ pct: 96 }, NOW), true, '96% of a week is spent, not 4% left');
+ assert.equal(router.windowFull({ used: 19, limit: 20 }, NOW), true, 'used/limit is the same reading');
+ assert.equal(router.windowFull({ pct: 100, resets_at: '2026-10-29T00:00:00Z' }, NOW), false, 'it freed yesterday');
+ assert.equal(router.windowFull({ pct: 40 }, NOW), false);
+ assert.equal(router.windowFull(null, NOW), false);
+ assert.equal(router.FULL_PCT, 95);
+ });
+
+ await check('room needs a signed-in agent with no full window', () => {
+ assert.equal(router.roomFor(TODAY, 'mm', NOW), true);
+ assert.equal(router.roomFor(TODAY, 'cx', NOW), false);
+ assert.equal(router.roomFor(TODAY, 'cc', NOW), false, '96% is full');
+ assert.equal(router.roomFor({ cx: { signed_in: false, windows: [] } }, 'cx', NOW), false, 'not signed in');
+ assert.equal(router.roomFor({}, 'cx', NOW), true, 'an agent the fleet does not name is unknown, not full');
+ assert.equal(router.roomFor(null, 'cx', NOW), true, 'no fleet at all leaves the ladder as it was');
+ });
+
+ await check('auto routes today to MiniMax or DeepSeek, never Codex, OpenCode or Command Code', () => {
+ for (const prompt of ['Standard feature: add the endpoint and its tests.', HARD, BASIC, 'Design pass on the task page: layout, css and a screenshot at 390.']) {
+ const picked = router.pick(prompt, context(TODAY));
+ assert.ok(picked, `something takes: ${prompt}`);
+ assert.ok(['mm', 'dsh'].includes(picked.runner), `${prompt} → ${picked.runner}`);
+ }
+ assert.equal(router.pick(HARD, context(TODAY)).runner, 'mm', 'hard starts at Codex, which is spent, so MiniMax');
+ assert.equal(router.pick(BASIC, context(TODAY)).runner, 'mm', 'basic starts at OpenCode (spent), Command Code (96%), then MiniMax');
+ });
+
+ await check('a plan with room keeps its place at the front of the ladder', () => {
+ const room = { ...TODAY, cx: { signed_in: true, windows: [{ name: 'week', pct: 20, resets_at: RESET_WEEK }] } };
+ assert.equal(router.pick(HARD, context(room)).runner, 'cx', 'Codex with room takes the hard task');
+ const ccRoom = { ...TODAY, cc: { signed_in: true, windows: [{ name: 'week', pct: 40, resets_at: RESET_WEEK }] } };
+ assert.equal(router.pick(BASIC, context(ccRoom)).runner, 'cc');
+ });
+
+ await check('a flat-price plan with room is preferred over a pay-per-token agent', () => {
+ // Both have room: the plan comes first, because the pay-per-token one costs money for every token.
+ const both = { ...TODAY, mm: { signed_in: true, windows: [] }, dsh: { signed_in: true, windows: [] } };
+ assert.equal(router.pick(BASIC, context(both)).runner, 'mm', 'the plan, not the meter');
+ assert.equal(router.pick(HARD, context(both)).runner, 'mm');
+ // And when the plan is spent, the pay-per-token one is what is left.
+ const spent = { ...both, mm: { signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET_WEEK }] } };
+ assert.equal(router.pick(BASIC, context(spent)).runner, 'dsh', 'DeepSeek is last, but it is the one with room');
+ });
+
+ await check('an agent the person is not signed in to is skipped for one that is', () => {
+ const out = { ...TODAY, cx: { signed_in: false, windows: [] }, mm: { signed_in: false, windows: [] } };
+ assert.equal(router.pick(HARD, context(out)).runner, 'dsh', 'Codex and MiniMax are signed out, so DeepSeek');
+ });
+
+ await check('an agent whose balance is nearly gone may not take a job', () => {
+ // DeepSeek reads its balance the way the portal reads it (engine/balance.js): $0.02 today.
+ assert.equal(router.MIN_BALANCE_USD, 1, 'a balance under a dollar would not pay for the job');
+ const broke = { ...TODAY, dsh: { ...TODAY.dsh, balance_usd: 0.02 } };
+ assert.equal(router.balanceLow(broke, 'dsh'), true, '$0.02 is too little');
+ assert.equal(router.roomFor(broke, 'dsh', NOW), false, 'so DeepSeek has no room');
+ assert.equal(router.pick(HARD, context(broke)).runner, 'mm', 'and the hard task goes to MiniMax');
+ const signedOut = { ...broke, cx: { signed_in: false, windows: [] }, mm: { signed_in: false, windows: [] } };
+ assert.equal(router.pick(HARD, context(signedOut)), null, 'nothing may take it: no agent is left, not a guess');
+
+ // A balance nobody read is unknown, not empty: the ladder keeps working.
+ assert.equal(router.balanceLow(TODAY, 'dsh'), false);
+ assert.equal(router.roomFor(TODAY, 'dsh', NOW), true);
+ assert.equal(router.pick(HARD, context(TODAY)).runner, 'mm');
+ // And a balance with money in it is no reason to skip anything.
+ const paid = { ...TODAY, dsh: { ...TODAY.dsh, balance_usd: 12.5 } };
+ assert.equal(router.roomFor(paid, 'dsh', NOW), true);
+ });
+ } catch (e) {
+ console.error(e);
+ process.exitCode = 1;
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,161 @@
@@ -0,0 +1,161 @@
+'use strict';
+
+/**
+ * The fleet (Codes C9c): what each of a person's machines reports with its heartbeat, kept per machine and answered for
+ * the Agents page, the router and the MCP tool. Bot is stood in for here; the database is the real one.
+ */
+const assert = require('node:assert/strict');
+const { check, done } = require('./helpers/boot');
+const { createStore } = require('../server/db');
+const { createFleet } = require('../server/domain/fleet');
+const { testDb } = require('./helpers/db');
+
+const SUBJECT = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2AB';
+const NOW = Date.parse('2026-10-30T12:00:00Z');
+const RESET = '2026-11-04T00:00:00Z';
+
+/** Bot's answer for one person's machines. */
+const machines = (list) => ({ ok: true, status: 200, json: async () => ({ machines: list }) });
+const answer = (doc) => ({ ok: true, status: 200, json: async () => doc });
+
+(async () => {
+ const testdb = await testDb();
+ const store = createStore(testdb.db, { now: () => NOW });
+ const log = { warn() {} };
+
+ try {
+ await check('the heartbeat report is stored per machine and agent, and answered per machine', async () => {
+ let doc = machines([
+ { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [
+ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', used: 100, limit: 100, resets_at: RESET }] },
+ { agent: 'minimax-code', installed: true, signed_in: true, windows: [] },
+ ] },
+ { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: false, fleet: [
+ { agent: 'opencode', installed: true, signed_in: false, windows: [] },
+ ] },
+ ]);
+ const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => doc });
+ const out = await fleet.refresh(SUBJECT, 'tok');
+ assert.equal(out.ok, true);
+ assert.equal(out.machines.length, 2);
+ const laptop = out.machines.find((m) => m.id === 'dev_ada_laptop');
+ assert.equal(laptop.node_id, 'nod_ada');
+ assert.equal(laptop.agents.length, 2);
+ assert.equal(laptop.agents[0].windows[0].pct, 100, 'the pct is derived from used/limit');
+ assert.equal(laptop.agents[0].windows[0].resets_at, new Date(RESET).toISOString());
+ const desk = out.machines.find((m) => m.id === 'dev_ada_desk');
+ assert.equal(desk.online, false);
+ });
+
+ await check('the page rows: one per agent, a state each, usable first', async () => {
+ const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
+ { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [
+ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', used: 100, limit: 100, resets_at: RESET }] },
+ { agent: 'minimax-code', installed: true, signed_in: true, windows: [{ name: 'week', used: 10, limit: 100, resets_at: RESET }] },
+ { agent: 'command-code', installed: true, signed_in: true, windows: [{ name: 'week', pct: 84, resets_at: RESET }] },
+ { agent: 'opencode', installed: true, signed_in: false, windows: [] },
+ { agent: 'deepseek', installed: false, signed_in: false, windows: [] },
+ ] },
+ ]) });
+ await fleet.refresh(SUBJECT, 'tok');
+ const rows = await fleet.agents(SUBJECT, null);
+ const by = Object.fromEntries(rows.map((r) => [r.agent, r]));
+ assert.equal(by['minimax-code'].state, 'ok');
+ assert.equal(by['command-code'].state, 'low', '84% of the week is nearly full');
+ assert.equal(by.codex.state, 'limited', 'a full window');
+ assert.equal(by.opencode.state, 'login', 'installed, not signed in');
+ assert.equal(by.deepseek.state, 'absent');
+ assert.equal(rows[0].agent, 'minimax-code', 'the one with the most room comes first');
+ assert.equal(by['minimax-code'].machines[0].machine, 'dev_ada_laptop');
+ });
+
+ await check('the router reads the fleet per runner, with the machine that has room', async () => {
+ const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
+ { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [
+ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET }] },
+ { agent: 'minimax-code', installed: true, signed_in: true, windows: [] },
+ ] },
+ ]) });
+ await fleet.refresh(SUBJECT, 'tok');
+ const forRouter = await fleet.forRouter(SUBJECT, (agent) => ({ 'minimax-code': 'mm', codex: 'cx' })[agent] || null);
+ assert.equal(forRouter.cx.room, false);
+ assert.equal(forRouter.mm.room, true);
+ assert.equal(forRouter.mm.machine, 'dev_ada_laptop');
+ assert.equal(forRouter.claude, undefined, 'Claude has no place in a sub-task fleet');
+ });
+
+ await check('a plan with room on one machine is usable, and the meter shows the fullest reading', async () => {
+ const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
+ { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [
+ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET }] },
+ ] },
+ { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: true, fleet: [
+ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 10, resets_at: RESET }] },
+ ] },
+ ]) });
+ await fleet.refresh(SUBJECT, 'tok');
+ const by = await fleet.byHarness(SUBJECT);
+ assert.equal(by.codex.windows[0].pct, 100, 'the fullest reading is the one a person sees');
+ assert.equal(by.codex.room, true, 'the desk has room, so a job can run');
+ assert.equal(by.codex.machine, 'dev_ada_desk', 'on the machine that has room');
+ const row = (await fleet.agents(SUBJECT, null)).find((a) => a.agent === 'codex');
+ assert.notEqual(row.state, 'limited', 'room on one machine is not full');
+ });
+
+ await check('a plan that is full on every machine is limited', async () => {
+ const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
+ { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [
+ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 100, resets_at: RESET }] },
+ ] },
+ { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: true, fleet: [
+ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', pct: 96, resets_at: RESET }] },
+ ] },
+ ]) });
+ await fleet.refresh(SUBJECT, 'tok');
+ const by = await fleet.byHarness(SUBJECT);
+ assert.equal(by.codex.room, false);
+ assert.equal((await fleet.agents(SUBJECT, null)).find((a) => a.agent === 'codex').state, 'limited');
+ });
+
+ await check('a machine gone from Bot\'s answer is gone here; one that reports no fleet keeps its reading', async () => {
+ const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
+ { id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet: [
+ { agent: 'codex', installed: true, signed_in: true, windows: [] },
+ ] },
+ { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: true, fleet: [
+ { agent: 'opencode', installed: true, signed_in: false, windows: [] },
+ ] },
+ ]) });
+ await fleet.refresh(SUBJECT, 'tok');
+ // The desk's Node is old and reports no fleet; the laptop is unplugged from the answer.
+ const next = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => machines([
+ { id: 'dev_ada_desk', node_id: 'nod_desk', name: 'ada-desk', online: true },
+ ]) });
+ await next.refresh(SUBJECT, 'tok');
+ const { machines: left } = await next.latest(SUBJECT);
+ assert.deepEqual(left.map((m) => m.id), ['dev_ada_desk'], 'the laptop is gone');
+ assert.equal(left[0].agents.length, 1, 'the desk keeps its last reading');
+ assert.equal(left[0].agents[0].agent, 'opencode');
+ });
+
+ await check('Bot that does not answer is a problem beside the stored fleet, never an empty one', async () => {
+ const down = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => { throw new Error('connect ECONNREFUSED'); } });
+ const out = await down.refresh(SUBJECT, 'tok');
+ assert.equal(out.ok, false);
+ assert.match(out.problem.detail, /did not answer/);
+ const { machines: kept } = await down.latest(SUBJECT);
+ assert.ok(kept.length >= 1, 'the last reading is still there');
+ });
+
+ await check('an expired session is said, not turned into an empty fleet', async () => {
+ const fleet = createFleet({ config: { bot: { internalUrl: 'http://bot.test' } }, store, log, now: () => NOW, fetchImpl: async () => ({ ok: false, status: 401, json: async () => ({ detail: 'unauthorized' }) }) });
+ await assert.rejects(fleet.refresh(SUBJECT, 'stale'), (e) => e.status === 401);
+ });
+ } catch (e) {
+ console.error(e);
+ process.exitCode = 1;
+ } finally {
+ await testdb.close();
+ }
+ done();
+})();
@@ -39,6 +39,22 @@ const json = (status, body) => ({ ok: status >= 200 && status < 300, status, jso
@@ -39,6 +39,22 @@ const json = (status, body) => ({ ok: status >= 200 && status < 300, status, jso
assert.strictEqual(calls.length, 2, 'the second read comes from the cache');
assert.strictEqual(calls.length, 2, 'the second read comes from the cache');
});
});
+ await check('a pull request carries its merge commit and the deploy state of the service it builds', async () => {
+ const fetchImpl = async (url) => {
+ if (url.endsWith('/pulls/12')) return json(200, { state: 'closed', merged: true, merge_commit_sha: 'a1b2c3d4e5f6', title: 'Ship it', head: { sha: 'abcdef1234', ref: 'codes/x' }, base: { ref: 'main' } });
+ if (url.includes('/status')) return json(200, { state: 'success' });
+ return json(404, {});
+ };
+ const seen = [];
+ const ship = { async deployOf(what) { seen.push(what); return { state: 'deployed', service: 'codes', release: 'a1b2c3d4e5f6' }; } };
+ const gh = createGitHub({ config: {}, fetchImpl, ship, log: { warn() {} } });
+ const pr = await gh.pullRequest('https://github.com/OpenVibers/OpenVibe.Codes/pull/12');
+ assert.strictEqual(pr.merged, true);
+ assert.strictEqual(pr.merge_commit_sha, 'a1b2c3d4e5f6');
+ assert.strictEqual(pr.deploy.state, 'deployed');
+ assert.deepStrictEqual(seen, [{ repo: 'OpenVibers/OpenVibe.Codes', mergeCommitSha: 'a1b2c3d4e5f6', merged: true }]);
+ });
+
await check('a pull request GitHub does not answer is a problem, never a guessed state', async () => {
await check('a pull request GitHub does not answer is a problem, never a guessed state', async () => {
const gh = createGitHub({ config: {}, fetchImpl: async () => json(404, { message: 'Not Found' }), log: { warn() {} } });
const gh = createGitHub({ config: {}, fetchImpl: async () => json(404, { message: 'Not Found' }), log: { warn() {} } });
const pr = await gh.pullRequest('https://github.com/o/private/pull/3');
const pr = await gh.pullRequest('https://github.com/o/private/pull/3');
@@ -13,7 +13,7 @@ process.env.CODES_RATE_LIMIT_PER_MIN = process.env.CODES_RATE_LIMIT_PER_MIN || '
@@ -13,7 +13,7 @@ process.env.CODES_RATE_LIMIT_PER_MIN = process.env.CODES_RATE_LIMIT_PER_MIN || '
* t.logs(), t.dbDump()
* t.logs(), t.dbDump()
*/
*/
const http = require('http');
const http = require('http');
-const { startNetwork } = require('./mocks');
+const { startNetwork, startBot } = require('./mocks');
const captured = [];
const captured = [];
for (const m of ['log', 'info', 'warn', 'error']) {
for (const m of ['log', 'info', 'warn', 'error']) {
@@ -23,10 +23,14 @@ for (const m of ['log', 'info', 'warn', 'error']) {
@@ -23,10 +23,14 @@ for (const m of ['log', 'info', 'warn', 'error']) {
async function boot(opts = {}) {
async function boot(opts = {}) {
const network = await startNetwork(opts.network || {});
const network = await startNetwork(opts.network || {});
+ // OpenVibe.Bot's machines (Codes C9c): a conductor's chosen machine is pinned on Run by the device id Bot names, and
+ // the fleet is read from the same answer. A test adds machines with t.bot.addMachine({ id: 'dev_…', node_id, … }).
+ const bot = await startBot();
const env = {
const env = {
NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.codes', TRUST_PROXY: '1',
NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.codes', TRUST_PROXY: '1',
OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
OV_OAUTH_CLIENT_ID: 'codes', OV_OAUTH_CLIENT_SECRET: 'codes-secret', COOKIE_SECURE: 'false',
OV_OAUTH_CLIENT_ID: 'codes', OV_OAUTH_CLIENT_SECRET: 'codes-secret', COOKIE_SECURE: 'false',
+ CODES_BOT_INTERNAL_URL: bot.url,
...(typeof opts.env === 'function' ? opts.env(network) : (opts.env || {})),
...(typeof opts.env === 'function' ? opts.env(network) : (opts.env || {})),
};
};
const configLib = require('../../server/config');
const configLib = require('../../server/config');
@@ -37,7 +41,7 @@ async function boot(opts = {}) {
@@ -37,7 +41,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
- const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}) });
+ const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}) });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
@@ -70,11 +74,12 @@ async function boot(opts = {}) {
@@ -70,11 +74,12 @@ async function boot(opts = {}) {
const t = {
const t = {
base, network, config, ctx: built.ctx, get, signIn, dbDump,
base, network, config, ctx: built.ctx, get, signIn, dbDump,
logs: () => captured.join('\n'),
logs: () => captured.join('\n'),
+ bot,
async close() {
async close() {
await new Promise((r) => server.close(r));
await new Promise((r) => server.close(r));
built.ctx.keys.client.stop();
built.ctx.keys.client.stop();
await testdb.close();
await testdb.close();
- await network.close();
+ await Promise.all([network.close(), bot.close()]);
},
},
};
};
return t;
return t;
@@ -390,4 +390,28 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -390,4 +390,28 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
};
};
}
}
-module.exports = { startNetwork, signJwt };
+/**
+ * An in-process stand-in for the part of OpenVibe.Bot Codes reads (Codes C9c): GET /api/v1/machines, the person's own
+ * machines with their fleet and their Network id. `machines` are seeded with addMachine({ id: 'dev_…', node_id,
+ * name, online, fleet, fleet_at }) — the device id is what Run pins a job to, the node id what Network knows it by.
+ * Every request is recorded, with its Bearer, so a test can assert who read what.
+ */
+async function startBot() {
+ const st = { machines: [], requests: [], down: false };
+ const srv = await listen((req, raw, json) => {
+ const url = new URL(req.url, 'http://x');
+ st.requests.push({ method: req.method, path: url.pathname, authorization: String(req.headers.authorization || '') });
+ if (st.down) return json(503, { code: 'bot.unavailable', detail: 'Bot is restarting' });
+ if (url.pathname !== '/api/v1/machines') return json(404, { code: 'route.not_found', detail: 'no such route' });
+ if (!String(req.headers.authorization || '').startsWith('Bearer ')) return json(401, { code: 'auth.required', detail: 'sign in to see your machines' });
+ return json(200, { machines: st.machines });
+ });
+ return {
+ url: srv.url, state: st, requests: st.requests,
+ addMachine(m) { const row = { online: true, fleet: null, fleet_at: null, ...m }; st.machines.push(row); return row; },
+ setDown(v) { st.down = v; },
+ close: srv.close,
+ };
+}
+
+module.exports = { startNetwork, startBot, signJwt };
@@ -80,6 +80,8 @@ async function fakeGitHub() {
@@ -80,6 +80,8 @@ async function fakeGitHub() {
const db = t.ctx.store.db;
const db = t.ctx.store.db;
const ada = t.network.addUser('ada');
const ada = t.network.addUser('ada');
t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });
t.network.addNode(ada.subject, { principal: NODE, node_id: 'node_ada', name: 'ada-laptop', status: 'active', last_seen_at: new Date().toISOString() });
+ // The machine, as OpenVibe.Bot answers it: the conductor is pinned to its device id, so Run never moves it.
+ t.bot.addMachine({ id: 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N2', node_id: NODE, name: 'ada-laptop', online: true });
const created = await t.get('/api/v1/conductor', { as: ada, json: { folder: '/home/ada/openvibe', machine: NODE } });
const created = await t.get('/api/v1/conductor', { as: ada, json: { folder: '/home/ada/openvibe', machine: NODE } });
assert.strictEqual(created.status, 201, created.text);
assert.strictEqual(created.status, 201, created.text);
const conductorId = created.json().task.id;
const conductorId = created.json().task.id;
@@ -177,13 +179,21 @@ async function fakeGitHub() {
@@ -177,13 +179,21 @@ async function fakeGitHub() {
assert.strictEqual(typeof a.installed, 'boolean');
assert.strictEqual(typeof a.installed, 'boolean');
assert.ok(Array.isArray(a.windows));
assert.ok(Array.isArray(a.windows));
}
}
+ // Codes answers with every machine's stored report: this machine is in Bot's list, but its Node has not
+ // reported a fleet yet, so there is nothing stored and nothing is invented.
+ assert.ok(Array.isArray(out.machines), 'the stored fleet of every machine');
+ assert.strictEqual(out.problem, undefined, 'Bot answered, so there is no problem beside the fleet');
+ assert.ok(out.agents.length >= 5, 'and the CLIs of this machine are read directly');
});
});
- await check('codes_ship_status: a pull request\'s CI and merge state, from GitHub', async () => {
+ await check('codes_ship_status: a pull request\'s CI, merge and deploy state, from GitHub', async () => {
const out = await mcp.call('codes_ship_status', { repo: 'octocat/Hello-World', pr: 7 });
const out = await mcp.call('codes_ship_status', { repo: 'octocat/Hello-World', pr: 7 });
assert.strictEqual(out.state, 'open');
assert.strictEqual(out.state, 'open');
assert.strictEqual(out.ci, 'Passed');
assert.strictEqual(out.ci, 'Passed');
+ assert.strictEqual(out.merged, false);
assert.strictEqual(out.title, 'Codes C9b: the conductor');
assert.strictEqual(out.title, 'Codes C9b: the conductor');
+ assert.strictEqual(out.deploy.state, 'unknown', 'Codes knows no service for this repository');
+ assert.strictEqual(out.deploy.service, null);
});
});
await check('codes_approve without a conductor task says so, instead of asking nothing', async () => {
await check('codes_approve without a conductor task says so, instead of asking nothing', async () => {
@@ -48,7 +48,9 @@ const { boot, check, done } = require('./helpers/boot');
@@ -48,7 +48,9 @@ const { boot, check, done } = require('./helpers/boot');
// The harness's environment (Codes C3) is not part of the task's own arguments: it carries the run token.
// The harness's environment (Codes C3) is not part of the task's own arguments: it carries the run token.
const { env, ...submitted } = submit.args;
const { env, ...submitted } = submit.args;
const args = Object.fromEntries(['repo', 'ref', 'prompt', 'permission', 'push', 'agent', 'model'].map((key) => [key, submitted[key]]));
const args = Object.fromEntries(['repo', 'ref', 'prompt', 'permission', 'push', 'agent', 'model'].map((key) => [key, submitted[key]]));
- assert.deepStrictEqual(args, { repo: ask.repo, ref: null, prompt: ask.prompt, permission: 'edit', push: null, agent: null, model: null });
+ // A task with no agent named is auto (Codes C9c): the router names the agent it picked, so the task and its
+ // first attempt carry it (no fleet has been reported here, so the standard ladder starts at MiniMax).
+ assert.deepStrictEqual(args, { repo: ask.repo, ref: null, prompt: ask.prompt, permission: 'edit', push: null, agent: 'minimax-code', model: null });
assert.deepStrictEqual(Object.keys(env || {}).sort(), ['OPENAI_API_KEY', 'OPENAI_BASE_URL'], 'the harness gets a run token and OpenVibe.AI\'s address');
assert.deepStrictEqual(Object.keys(env || {}).sort(), ['OPENAI_API_KEY', 'OPENAI_BASE_URL'], 'the harness gets a run token and OpenVibe.AI\'s address');
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+'use strict';
+
+/**
+ * Notices (Codes C9c): "done" and "needs you" reach the person through Network's notifications, once per task and
+ * state, and the two Settings switches (notify_done, notify_needs) are honoured. The Action Center is not a push: it
+ * reads the tasks themselves, so this only covers the notification.
+ */
+const assert = require('assert');
+const { check, done } = require('./helpers/boot');
+const { createStore } = require('../server/db');
+const { createNotices } = require('../server/domain/notices');
+const { createUserSettings } = require('../server/domain/user-settings');
+const { testDb } = require('./helpers/db');
+
+const SUBJECT = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2AB';
+
+(async () => {
+ const testdb = await testDb();
+ const store = createStore(testdb.db);
+ const settings = createUserSettings({ store });
+
+ try {
+ await check('done and needs reach the person through Network, with a way back to the task', async () => {
+ const sent = [];
+ const notices = createNotices({ store, config: { baseUrl: 'https://openvibe.codes' }, userSettings: settings, notify: { async push(body) { sent.push(body); return { sent: true }; } } });
+ const task = { id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2AB', subject: SUBJECT };
+ assert.equal((await notices.done(task, 'It added the endpoint and the tests.')).sent, true);
+ assert.equal((await notices.needs(task, 'It stopped at its budget.')).sent, true);
+ assert.equal(sent.length, 2);
+ assert.equal(sent[0].title, 'Your task is done');
+ assert.equal(sent[0].url, `https://openvibe.codes/tasks/${task.id}`);
+ assert.equal(sent[0].category, 'codes');
+ assert.equal(sent[1].title, 'Your task needs you');
+ assert.equal(sent[1].priority, 'high');
+ assert.match(sent[1].message, /budget/);
+ });
+
+ await check('one notice per task and state: a redelivered event does not tell the person twice', async () => {
+ const sent = [];
+ const notices = createNotices({ store, config: { baseUrl: 'https://openvibe.codes' }, userSettings: settings, notify: { async push(body) { sent.push(body); return { sent: true }; } } });
+ const task = { id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2AC', subject: SUBJECT };
+ assert.equal((await notices.done(task)).sent, true);
+ const again = await notices.done(task);
+ assert.equal(again.sent, false);
+ assert.equal(again.reason, 'already');
+ assert.equal(sent.length, 1, 'the second call sent nothing');
+ });
+
+ await check('a switch that is off is not an error, and the notice is not retried', async () => {
+ await settings.save(SUBJECT, { notify_done: false, notify_needs: false });
+ const sent = [];
+ const notices = createNotices({ store, config: { baseUrl: 'https://openvibe.codes' }, userSettings: settings, notify: { async push(body) { sent.push(body); return { sent: true }; } } });
+ const task = { id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2AD', subject: SUBJECT };
+ assert.equal((await notices.done(task, 'finished')).reason, 'switched_off');
+ assert.equal((await notices.needs(task, 'needs a person')).reason, 'switched_off');
+ assert.equal(sent.length, 0, 'nothing was pushed');
+ });
+
+ await check('a Network that does not answer does not stop the task, and is said', async () => {
+ await settings.save(SUBJECT, { notify_done: true, notify_needs: true });
+ const notices = createNotices({ store, config: { baseUrl: 'https://openvibe.codes' }, userSettings: settings, log: { warn() {} }, notify: { async push() { throw new Error('network down'); } } });
+ const task = { id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2AE', subject: SUBJECT };
+ const out = await notices.needs(task, 'it could not start');
+ assert.equal(out.sent, false);
+ assert.equal(out.reason, 'network_error');
+ });
+
+ await check('a task with no person, or no task, sends nothing', async () => {
+ const notices = createNotices({ store, config: {}, userSettings: settings, notify: { async push() { throw new Error('should not be called'); } } });
+ assert.equal((await notices.done(null)).reason, 'bad_input');
+ assert.equal((await notices.done({ id: 'not-a-task', subject: SUBJECT })).reason, 'bad_input');
+ assert.equal((await notices.done({ id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2AF' })).reason, 'no_subject');
+ assert.equal((await notices.send({ id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2AF', subject: SUBJECT }, 'nope')).reason, 'bad_input');
+ });
+ } catch (e) {
+ console.error(e);
+ process.exitCode = 1;
+ } finally {
+ await testdb.close();
+ }
+ done();
+})();
@@ -18,7 +18,7 @@ const BUDGETS = {
@@ -18,7 +18,7 @@ const BUDGETS = {
jsRawKB: 340, // 330.9 after C9b: the conductor's message box with its images and its live turn (2.6 KB in js/c4.js); 326.2 after C4b: the home page loads the workspace script for its repository search (10.9 KB of its own); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsRawKB: 340, // 330.9 after C9b: the conductor's message box with its images and its live turn (2.6 KB in js/c4.js); 326.2 after C4b: the home page loads the workspace script for its repository search (10.9 KB of its own); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 84, // 82.9 after C9b's conductor script; 80.6 after C4b's repository search on the home page; 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
jsBrotliKB: 84, // 82.9 after C9b's conductor script; 80.6 after C4b's repository search on the home page; 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 3, // C4 adds its responsive workspace stylesheet alongside codes.css and run.css.
cssFiles: 3, // C4 adds its responsive workspace stylesheet alongside codes.css and run.css.
- cssRawKB: 64, // 62.4 after C9b: the conductor's turns, approvals inbox and composer (public/css/c4.css); 58.4 after C4b: the proof rows, the fold-out panels, the shots, the forms that remember choices and their light/dark styles; 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.
+ cssRawKB: 65, // 63.0 after C9c: the fleet's rows and its meters on the Agents page (public/css/c4.css, 1.2 KB); 62.4 after C9b: the conductor's turns, approvals inbox and composer (public/css/c4.css); 58.4 after C4b: the proof rows, the fold-out panels, the shots, the forms that remember choices and their light/dark styles; 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.
cssBrotliKB: 14, // 13.0 after C9b's conductor styles; 12.1 after C4b's rows, panels and forms; 9.9 KB after adding the C4 workspace styles.
cssBrotliKB: 14, // 13.0 after C9b's conductor styles; 12.1 after C4b's rows, panels and forms; 9.9 KB after adding the C4 workspace styles.
externalFiles: 2, // 0
externalFiles: 2, // 0
};
};
@@ -55,6 +55,8 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -55,6 +55,8 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person',
'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person',
'server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person',
'server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person',
'server/domain/github.js': 'api.github.com only, at fixed paths: the public repository search and a pull request\'s state. The owner/name/number come from a Codes-stored task result and are checked against GitHub\'s id shapes before they are put in a path',
'server/domain/github.js': 'api.github.com only, at fixed paths: the public repository search and a pull request\'s state. The owner/name/number come from a Codes-stored task result and are checked against GitHub\'s id shapes before they are put in a path',
+ 'server/domain/fleet.js': 'OpenVibe.Bot (CODES_BOT_INTERNAL_URL, configured) /api/v1/machines with the person\'s own session token: their machines and the fleet their Nodes reported; the path is fixed, never a URL from a person',
+ 'server/domain/ship.js': 'the service\'s own GET <publicOrigin>/release.json and api.github.com\'s compare, both at fixed paths: the origin is the contracts\' service manifest, never a URL from a person; the shas are checked hex',
'server/events-consumer.js': 'OpenVibe.Events subscriptions (EVENTS_URL, configured) at boot; no URL from a person',
'server/events-consumer.js': 'OpenVibe.Events subscriptions (EVENTS_URL, configured) at boot; no URL from a person',
'server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',
'server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+
+/**
+ * Ship tracking (Codes C9c): after CI and the merge, is the service deployed? The signal is the host's own —
+ * `GET <publicOrigin>/release.json`, which `ovhost status` reads — compared with the pull request's merge commit. Nothing
+ * is guessed from a timer.
+ */
+const assert = require('assert');
+const { check, done } = require('./helpers/boot');
+const { createShip, serviceFor } = require('../server/domain/ship');
+
+const MANIFESTS = [
+ { id: 'codes', repository: 'OpenVibers/OpenVibe.Codes', publicOrigin: 'https://openvibe.codes' },
+ { id: 'run', repository: 'OpenVibers/OpenVibe.Run', publicOrigin: 'https://run.openvibe.network' },
+ { id: 'internal', repository: 'OpenVibers/OpenVibe.Bot' },
+];
+const MERGE = 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678';
+const AFTER = 'ffffffffffffffffffffffffffffffffffffffff';
+
+const json = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body });
+
+/** A release.json per origin, and GitHub's compare, stood in for. */
+function world({ release = null, originOk = true, compare = null } = {}) {
+ const calls = [];
+ const fetchImpl = async (url) => {
+ calls.push(url);
+ if (url === 'https://openvibe.codes/release.json') return originOk ? json(200, { service: 'codes', release }) : json(503, {});
+ if (url.includes('/compare/')) return compare == null ? json(502, {}) : json(200, { status: compare });
+ return json(404, {});
+ };
+ return { fetchImpl, calls };
+}
+
+(async () => {
+ try {
+ await check('a repository is tied to the service its manifest names, and only when it has a public origin', () => {
+ assert.equal(serviceFor(MANIFESTS, 'openvibers/openvibe.codes').id, 'codes', 'the match is case-insensitive');
+ assert.equal(serviceFor(MANIFESTS, 'OpenVibers/OpenVibe.Bot'), null, 'a manifest without an origin is not deployable');
+ assert.equal(serviceFor(MANIFESTS, 'nobody/else'), null);
+ assert.equal(serviceFor(MANIFESTS, null), null);
+ });
+
+ await check('the merge commit the service reports is deployed', async () => {
+ const { fetchImpl } = world({ release: MERGE });
+ const ship = createShip({ config: {}, fetchImpl, manifests: MANIFESTS, log: { warn() {} } });
+ const out = await ship.deployOf({ repo: 'OpenVibers/OpenVibe.Codes', mergeCommitSha: MERGE, merged: true });
+ assert.equal(out.state, 'deployed');
+ assert.equal(out.service, 'codes');
+ assert.equal(out.release, MERGE);
+ assert.equal(out.origin, 'https://openvibe.codes');
+ });
+
+ await check('a release that contains the merge commit (GitHub says ahead or identical) is deployed', async () => {
+ const { fetchImpl } = world({ release: AFTER, compare: 'ahead' });
+ const ship = createShip({ config: {}, fetchImpl, manifests: MANIFESTS, log: { warn() {} } });
+ const out = await ship.deployOf({ repo: 'OpenVibers/OpenVibe.Codes', mergeCommitSha: MERGE, merged: true });
+ assert.equal(out.state, 'deployed');
+ });
+
+ await check('merged but behind the service\'s release is not live yet', async () => {
+ const { fetchImpl } = world({ release: AFTER, compare: 'behind' });
+ const ship = createShip({ config: {}, fetchImpl, manifests: MANIFESTS, log: { warn() {} } });
+ const out = await ship.deployOf({ repo: 'OpenVibers/OpenVibe.Codes', mergeCommitSha: MERGE, merged: true });
+ assert.equal(out.state, 'pending');
+ assert.match(out.detail, /not deployed yet/);
+ });
+
+ await check('an unmerged pull request waits, and a service that does not answer is not read', async () => {
+ const { fetchImpl } = world({ release: MERGE });
+ const ship = createShip({ config: {}, fetchImpl, manifests: MANIFESTS, log: { warn() {} } });
+ assert.equal((await ship.deployOf({ repo: 'OpenVibers/OpenVibe.Codes', mergeCommitSha: null, merged: false })).state, 'waiting');
+ const down = createShip({ config: {}, fetchImpl: world({ originOk: false }).fetchImpl, manifests: MANIFESTS, log: { warn() {} } });
+ const out = await down.deployOf({ repo: 'OpenVibers/OpenVibe.Codes', mergeCommitSha: MERGE, merged: true });
+ assert.equal(out.state, 'unknown');
+ assert.match(out.detail, /release\.json/);
+ });
+
+ await check('a repository Codes knows no service for has no deploy state, not a wrong one', async () => {
+ const { fetchImpl, calls } = world({ release: MERGE });
+ const ship = createShip({ config: {}, fetchImpl, manifests: MANIFESTS, log: { warn() {} } });
+ const out = await ship.deployOf({ repo: 'octocat/Hello-World', mergeCommitSha: MERGE, merged: true });
+ assert.equal(out.state, 'unknown');
+ assert.equal(out.service, null);
+ assert.equal(calls.length, 0, 'no origin was asked');
+ });
+ } catch (e) {
+ console.error(e);
+ process.exitCode = 1;
+ }
+ done();
+})();