openvibers/openvibe.bot Commits

Bot: keep what a machine's coding agents have left (#232)

OP OpenVibers committed 14 days ago · c3bc40431f · 8 files, +256 −4

A machine's OpenVibe.Node reports its coding agents with every heartbeat
(Codes C9c): each agent's id, whether it is installed and signed in, and
each limit window's label, used and limit and when it frees. Bot keeps the
latest reading per machine and when it stored it, so Codes' Agents page, its
router and its MCP can ask one service what a machine has left.

The reading is bounded on arrival exactly as the Node bounds it before
sending, because a frame is whatever the socket says: an agent id shaped
like an id, at most 16 agents, at most 10 windows each, a short label and
reset time, two finite numbers. Bot adds nothing of its own: no key, no
token, no account name. A frame that is not a reading changes nothing; an
empty array is a real answer.

The reading is stored when it changed or is a minute old, so a machine that
keeps beating keeps its time current without a write per beat, and one that
stops beating keeps its last reading with an age that says so.

GET /api/v1/machines answers the reading and the machine's Network id
(nod_…). The owner's own export carries the reading too: it is theirs.

migrations/0027_machine_fleet.sql is expand-only: two columns on devices
(fleet jsonb, fleet_at timestamptz), both nullable, both defaulted to
nothing.

Changes

ddiff --git a/migrations/0027_machine_fleet.sql b/migrations/0027_machine_fleet.sql
nnew file mode 100644
iindex 0000000..b6ab9fb
---- /dev/null
++++ b/migrations/0027_machine_fleet.sql
@@@ -0,0 +1,8 @@
++-- phase: expand
++-- What a machine's coding agents have left (Codes C9c, bot.device-message@1's heartbeat `fleet`): each agent's id,
++-- whether it is installed and signed in, and each limit window's label, what it has used of its limit, and when it
++-- frees. Its OpenVibe.Node reads it with OpenVibe.Codes' own logins probe as the machine's user and reports labels,
++-- numbers and times only: no key, token or account name. Bot keeps the latest reading per machine and when it stored
++-- it; a machine that stops beating keeps its last reading, and fleet_at says how old it is.
++ALTER TABLE devices ADD COLUMN IF NOT EXISTS fleet jsonb;
++ALTER TABLE devices ADD COLUMN IF NOT EXISTS fleet_at timestamptz;
ddiff --git a/server/account-data.js b/server/account-data.js
iindex b704b38..7af1ff9 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -37,8 +37,9 @@ const TABLES = [
{ table: 'profile_submissions', subject: 'submitter_subject', file: 'robot-submissions.json', columns: ['profile_id', 'profile_version', 'profile', 'driver_url', 'guide_url', 'license', 'notes', 'status', 'review_note', 'created_at'], order: 'created_at', erase: { keep: 'approved profiles stay in the catalogue, authorless (extraErase)' } },
{ table: 'profile_submissions', subject: 'reviewed_by', file: null, erase: anonymize },
// Their own machines that run their jobs (plan T14/T16): exported here; revoked and made nameless and ownerless in
-- // extraErase (run jobs reference the device rows and are billing records).
-- { table: 'devices', subject: 'owner_subject', file: 'machines.json', columns: ['id', 'name', 'kind', 'agent_version', 'created_at', 'last_seen', 'revoked_at'], order: 'created_at', erase: { keep: 'revoked and made ownerless in extraErase' } },
++ // extraErase (run jobs reference the device rows and are billing records). What the machine's coding agents last
++ // reported (fleet, fleet_at; Codes C9c) is theirs too: labels, numbers and times only, never a key or a token.
++ { table: 'devices', subject: 'owner_subject', file: 'machines.json', columns: ['id', 'name', 'kind', 'agent_version', 'created_at', 'last_seen', 'revoked_at', 'fleet', 'fleet_at'], order: 'created_at', erase: { keep: 'revoked and made ownerless in extraErase' } },
];
async function extraExport(db, subject) {
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 174d767..3e6fa31 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -435,6 +435,13 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
return {
id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer, ai_models: !!device.ai_models, watch_probe: !!device.watch_probe,
shared_desks: !!device.shared_desks,
++ // The machine's id on Network (`nod_…`) and the OpenVibe.Node's name for it, so a caller that knows the
++ // machine from Network (Codes' fleet, a Run pin) finds this row (Codes C9c).
++ node_id: device.node_principal || null,
++ // What its coding agents have left, as its Node last reported it, and when Bot stored that reading
++ // (Codes C9c). null: the machine has reported none (no OpenVibe.Codes runner, or no reading yet).
++ fleet: json(device.fleet, null),
++ fleet_at: device.fleet_at ? new Date(device.fleet_at).toISOString() : null,
voice: { on: !!device.voice_on, call_word: device.call_word || DEFAULT_CALL_WORD },
computer: offered ? {
level: typeof sensors.computer_level === 'string' ? sensors.computer_level : ((pick('files') || pick('terminal') || pick('screen') || {}).level || null),
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 10ebb4b..72860fe 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -863,6 +863,18 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
[id, kind ?? null, drivers ? JSON.stringify(drivers) : null, capabilities ? JSON.stringify(capabilities) : null,
agentVersion !== undefined, agentVersion ?? null, iso(now())]);
}
++ /**
++ * Keep the machine's latest fleet reading and when Bot stored it (Codes C9c). The reading is already bounded by
++ * server/fleet.js; this is the row. A machine that has stopped beating keeps its last reading, and fleet_at says
++ * how old it is.
++ */
++ async function setFleet(id, fleet) {
++ changed();
++ return db.maybe(
++ `UPDATE devices SET fleet = $2::jsonb, fleet_at = $3, updated_at = $3
++ WHERE id = $1 AND revoked_at IS NULL RETURNING *`,
++ [id, JSON.stringify(fleet), iso(now())]);
++ }
/** Revoke a Network-paired device's principal on Network too (null: Network no longer knows it). */
async function revokeNode(principalId) {
if (!nodes) fail(503, 'bot.network_unavailable', 'Network-paired machines are not configured here');
@@@ -1315,7 +1327,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
localProfile, saveLocalProfile, useCatalogueProfile },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },
-- devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },
++ devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, setFleet, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },
machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice, setSharedDesks, listSharedDesks,
screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
deskLeases,
ddiff --git a/server/fleet.js b/server/fleet.js
nnew file mode 100644
iindex 0000000..006e1f9
---- /dev/null
++++ b/server/fleet.js
@@@ -0,0 +1,66 @@
++'use strict';
++
++/**
++ * A machine's coding agents, as its OpenVibe.Node reports them (Codes C9c).
++ *
++ * The Node reads them with OpenVibe.Codes' own logins probe (`openvibe-codes logins --json`, as the machine's user)
++ * and sends the reading with every heartbeat (`bot.device-message@1`'s `fleet`): each agent's id, whether it is
++ * installed and signed in, and each limit window's label, what it has used of its limit, and when it frees. Bot keeps
++ * the latest reading per machine (`devices.fleet`, `devices.fleet_at`) for Codes' Agents page, its router and its MCP.
++ *
++ * What arrives is bounded here exactly as the Node bounds it before sending, because a frame is whatever the socket
++ * says: an agent id shaped like an id, at most 16 agents, at most 10 windows each, a short label and reset time, two
++ * finite numbers. A reading never carries a key, a token or an account name, and Bot adds none: it stores what it is
++ * sent, and nothing else. `null` means the frame was not a reading at all (not an array) and the last one stands; an
++ * empty array is a real answer — the probe ran and found no agents.
++ */
++const MAX_AGENTS = 16;
++const MAX_WINDOWS = 10;
++const LABEL_MAX = 40;
++const AGENT_ID = /^[a-z0-9-]{1,40}$/;
++
++/** A trimmed string of at most `max` characters, or null when it is not a non-empty string. */
++function label(v, max = LABEL_MAX) {
++ if (typeof v !== 'string') return null;
++ const s = v.trim();
++ return s && s.length <= max ? s : null;
++}
++
++function windowOf(raw) {
++ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
++ const name = label(raw.name);
++ if (!name) return null;
++ const w = { name };
++ for (const k of ['used', 'limit']) if (typeof raw[k] === 'number' && Number.isFinite(raw[k])) w[k] = raw[k];
++ const resets = label(raw.resets_at);
++ if (resets) w.resets_at = resets;
++ return w;
++}
++
++function agentOf(raw) {
++ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
++ if (typeof raw.agent !== 'string' || !AGENT_ID.test(raw.agent)) return null;
++ const a = { agent: raw.agent, installed: raw.installed === true, signed_in: raw.signed_in === true };
++ if (Array.isArray(raw.windows)) {
++ const windows = raw.windows.map(windowOf).filter(Boolean).slice(0, MAX_WINDOWS);
++ if (windows.length) a.windows = windows;
++ }
++ return a;
++}
++
++/** The bounded reading a heartbeat carried, or null when it carried none (a non-array `fleet`). */
++function sanitize(raw) {
++ if (!Array.isArray(raw)) return null;
++ const out = [];
++ const seen = new Set();
++ for (const r of raw) {
++ const a = agentOf(r);
++ if (!a || seen.has(a.agent)) continue;
++ seen.add(a.agent);
++ out.push(a);
++ if (out.length >= MAX_AGENTS) break;
++ }
++ return out;
++}
++
++module.exports = { sanitize, MAX_AGENTS, MAX_WINDOWS, LABEL_MAX };
ddiff --git a/server/realtime.js b/server/realtime.js
iindex df81ff9..c7b87e3 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -29,12 +29,16 @@ const { userPrincipal, verifyService, verifyNode, isNodeToken, PRINCIPAL_SUB, de
const { getProfile } = require('./profiles');
const { createVoice } = require('./voice');
const { computerCaps } = require('./machine-ai');
++const { sanitize: sanitizeFleet } = require('./fleet');
const OPEN = 1;
const MAX_BACKLOG = 64; // frames a device may send before its authentication completes
const ACCESS_COOKIE = 'ov_token';
const DEVICE_KINDS = new Set(['onboard', 'bridge', 'server']);
const MAX_AGENT_VERSION = 40;
++// How often a machine's unchanged fleet reading is stored again (Codes C9c): its Node re-reads its agents' logins
++// every minute, so a minute is as fresh as the reading can be, and a beat every second would be a write a second.
++const FLEET_REFRESH_MS = 60_000;
const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leave
// A machine's answer to a computer command (exec output, a file, a screen) is at most 512 KB of JSON in its ack
// (OpenVibe.Node protocol.MaxAckData), so a device frame may be that large; ws closes a socket on a larger frame.
@@@ -372,6 +376,23 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
}
++ /**
++ * Keep what the machine's coding agents have left (Codes C9c), from the heartbeat's `fleet`. The reading is
++ * stored when it changed or is a minute old, so a machine that keeps beating keeps `fleet_at` current without a
++ * write per heartbeat, and one that stops beating keeps its last reading with an age that says so. Never awaited:
++ * the ack is the heartbeat's answer, and a machine neither waits on nor hears about a store that failed.
++ */
++ function noteFleet(conn, raw) {
++ if (conn.sim || !conn.device) return;
++ const fleet = sanitizeFleet(raw);
++ if (fleet === null) return;
++ const key = JSON.stringify(fleet);
++ if (conn.fleetKey === key && now() - conn.fleetAt < FLEET_REFRESH_MS) return;
++ conn.fleetKey = key;
++ conn.fleetAt = now();
++ domain.devices.setFleet(conn.device.id, fleet).catch((e) => log.warn(`[Bot] machine ${conn.device.id} fleet: ${e.message}`));
++ }
++
async function handleDeviceMessage(conn, raw) {
let msg; try { msg = JSON.parse(String(raw)); } catch { return sendError(conn, 'bot.bad_json', 'frames must be JSON'); }
if (!msg || typeof msg !== 'object') return sendError(conn, 'bot.bad_frame', 'a frame must be an object');
@@@ -383,6 +404,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
switch (msg.type) {
case 'heartbeat': {
if (Number.isFinite(msg.rtt_ms)) conn.rttMs = Math.round(msg.rtt_ms);
++ // What the machine's coding agents have left (Codes C9c), stored for Codes' Agents page and router.
++ if (msg.fleet !== undefined) noteFleet(conn, msg.fleet);
// `echo` carries the device's `t` back for its RTT; the envelope `seq` stays Bot's own counter.
const ack = { echo: msg.t !== undefined ? msg.t : null, server_time: iso(now()) };
// OpenVibe.Node reads `heartbeat_ack.t` (its RTT is now - t), so echo it as `t` as well.
ddiff --git a/server/web/docs.js b/server/web/docs.js
iindex a5fa7d5..8a71bcc 100644
---- a/server/web/docs.js
++++ b/server/web/docs.js
@@@ -449,7 +449,7 @@ The page answers with its URL, title, text and links. The browser is the agent's
<li><code>ai</code>: <code>model</code> (one of its models), <code>messages</code> (<code>role</code> and <code>content</code>), <code>max_tokens</code>, <code>temperature</code> → <code>message</code>, <code>usage</code> and <code>ms</code>, from the computer's own model server. <code>POST /api/v1/machines/:id/ai-models</code> with <code>on</code> is the switch above; OpenVibe's own AI uses the models only while it is on.</li>
<li><code>probe</code>: <code>target</code> (<code>http(s)://…</code> or <code>tcp://host:port</code> on the computer's own network) → the status and body of one GET, or whether the port is open. It is read-only and runs at every level.</li>
</ul>
--<p class="muted"><code>GET /api/v1/machines</code> lists your machines with their level. A machine whose plugin is off answers <code>409 bot.computer_off</code>; a refusal on the computer answers <code>result: "nack"</code> with the reason. The audit keeps the program name or the file and path, never its content.</p></section>
++<p class="muted"><code>GET /api/v1/machines</code> lists your machines with their level, its <code>node_id</code> on Network (<code>nod_…</code>) and, for a machine running OpenVibe.Codes agents, the <code>fleet</code> its Node last reported (<code>fleet_at</code> says when): each agent's id, whether it is installed and signed in, and each limit window's label, used and limit and when it frees — labels, numbers and times only, never a key, a token or an account name. A machine whose plugin is off answers <code>409 bot.computer_off</code>; a refusal on the computer answers <code>result: "nack"</code> with the reason. The audit keeps the program name or the file and path, never its content.</p></section>
`;
return docsPage({ config, path: '/docs/computers', title: 'Your computer for your agents', description: 'Let your agents use your own computer through the OpenVibe Node: a desk of its own for each task (watch it and take it over), its terminal, files, screen, mouse and keyboard, at the level you set on the computer, with approvals there and a kill switch; and let your AI think with the models you run on it.', content });
}
ddiff --git a/test/machine-fleet.test.js b/test/machine-fleet.test.js
nnew file mode 100644
iindex 0000000..17145e2
---- /dev/null
++++ b/test/machine-fleet.test.js
@@@ -0,0 +1,135 @@
++'use strict';
++// The fleet (Codes C9c): a machine's OpenVibe.Node reports what its coding agents have left with each heartbeat
++// (bot.device-message@1's `fleet`), and Bot keeps the latest reading per machine for Codes' Agents page, its router and
++// its MCP. GET /api/v1/machines answers it with the machine's Network id (`node_id`, nod_…), which is the same machine
++// Run pins a job to by its device id (dev_…): one row, both names.
++//
++// What this checks: a reading is stored and answered; it is bounded exactly as the Node bounds it (16 agents, 10 windows,
++// an id shaped like an id, short labels, numbers only), and a frame that is not a reading changes nothing; the owner
++// sees their own reading and nobody else does; a machine that has reported nothing answers null; the reading is stored
++// again when it changes or a minute has passed, never on every beat.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0' } });
++ const alex = t.network.newUser('alex');
++ const bob = t.network.newUser('bob');
++ let seq = 0;
++ const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });
++ const poll = async (fn, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(30); } return null; };
++ const rowOf = (id) => t.db.maybe('SELECT * FROM devices WHERE id = $1', [id]);
++ const at = (v) => (v == null ? null : new Date(v).getTime());
++ const list = (v) => (typeof v === 'string' ? JSON.parse(v) : v);
++ const machines = async (user) => { const r = await t.call('GET', '/api/v1/machines', { user }); assert.strictEqual(r.status, 200, r.text); return r.json.machines; };
++
++ /** A Network-paired machine of `owner`, beating like a real Node (its own timer, no fleet) until closed. */
++ async function machine(owner) {
++ const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });
++ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
++ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;
++ await c.waitFor((m) => m.type === 'config');
++ const beat = (fields = {}) => c.send(frame('heartbeat', { t: Date.now(), ...fields }));
++ const keep = setInterval(() => { try { beat(); } catch { /* closed */ } }, 80);
++ c.onClose = () => clearInterval(keep);
++ return { c, deviceId, principal, beat };
++ }
++
++ const FLEET = [
++ { agent: 'codex', installed: true, signed_in: true, windows: [{ name: 'week', used: 100, limit: 100, resets_at: '2026-11-04T00:00:00Z' }] },
++ { agent: 'opencode', installed: true, signed_in: false },
++ ];
++
++ let mine;
++ let bare;
++ try {
++ await check('a machine\'s fleet is stored and answered with its Network id', async () => {
++ mine = await machine(alex);
++ mine.beat({ fleet: FLEET });
++ const row = await poll(async () => { const r = await rowOf(mine.deviceId); return r && r.fleet && r.fleet_at ? r : null; });
++ assert.ok(row, 'the reading was stored');
++ assert.deepStrictEqual(list(row.fleet), FLEET, 'labels, numbers and times, exactly as sent');
++ assert.ok(at(row.fleet_at) > Date.now() - 60_000, 'fleet_at says when Bot stored it');
++
++ const [m] = await machines(alex);
++ assert.strictEqual(m.id, mine.deviceId);
++ assert.strictEqual(m.node_id, mine.principal, 'the machine\'s Network id: how Codes, and a Run pin, name it');
++ assert.deepStrictEqual(m.fleet, FLEET);
++ assert.strictEqual(m.fleet_at, new Date(row.fleet_at).toISOString());
++ });
++
++ await check('the reading is bounded, and a frame that is not a reading changes nothing', async () => {
++ const many = Array.from({ length: 40 }, (_, i) => ({
++ agent: `agent-${i}`,
++ installed: true,
++ signed_in: i % 2 === 0,
++ windows: Array.from({ length: 12 }, (_, w) => ({ name: `w${w}`, used: 1, limit: 100, resets_at: '2026-11-02T00:00:00Z' })),
++ }));
++ mine.beat({ fleet: many });
++ const row = await poll(async () => { const r = await rowOf(mine.deviceId); const f = list(r && r.fleet); return f && f.length === 16 ? r : null; });
++ assert.ok(row, '16 agents at most');
++ assert.ok(list(row.fleet).every((a) => a.windows.length === 10), '10 windows each');
++
++ // What the sanitizer drops: an agent id that is not an id, a repeated agent, a window it cannot label
++ // faithfully (no name, or a name too long), a number that is not a number. What it keeps: the agent and its
++ // windows, and nothing it was not sent.
++ mine.beat({ fleet: [
++ { agent: 'Bad ID!', installed: true, signed_in: true },
++ { agent: 'deepseek', installed: true, signed_in: true, api_key: 'sk-secret', account: '[email protected]',
++ windows: [{ name: 'month', used: 96, limit: 'a lot', account: '[email protected]' }, { used: 3 }, { name: 'x'.repeat(80), used: 1 }] },
++ { agent: 'deepseek', installed: false, signed_in: false },
++ { agent: 'minimax-code', installed: true, signed_in: true },
++ ] });
++ const clean = await poll(async () => { const r = await rowOf(mine.deviceId); const f = list(r && r.fleet); return f && f.length === 2 && f[0].agent === 'deepseek' ? r : null; });
++ assert.ok(clean, JSON.stringify(clean && list(clean.fleet)));
++ assert.deepStrictEqual(list(clean.fleet), [
++ { agent: 'deepseek', installed: true, signed_in: true, windows: [{ name: 'month', used: 96 }] },
++ { agent: 'minimax-code', installed: true, signed_in: true },
++ ]);
++
++ // Not an array: no reading, so the last one stands.
++ const before = await rowOf(mine.deviceId);
++ mine.beat({ fleet: { codex: 'full' } });
++ await t.wait(300);
++ assert.deepStrictEqual((await rowOf(mine.deviceId)).fleet, before.fleet);
++
++ // An empty array is a reading: the probe ran and found no agents.
++ bare = await machine(bob);
++ bare.beat({ fleet: [] });
++ const empty = await poll(async () => { const r = await rowOf(bare.deviceId); return r && Array.isArray(r.fleet) ? r : null; });
++ assert.deepStrictEqual(empty.fleet, []);
++ assert.ok(empty.fleet_at, 'it is a reading, taken and stored like any other');
++ });
++
++ await check('the owner sees their own machine, its reading and the Network id; nobody else sees it', async () => {
++ const machineOf = async (user) => (await machines(user)).find((m) => m.id === mine.deviceId) || null;
++ const asAlex = await machineOf(alex);
++ assert.deepStrictEqual([asAlex.id, asAlex.node_id, Array.isArray(asAlex.fleet)], [mine.deviceId, mine.principal, true]);
++ assert.strictEqual(await machineOf(bob), null, 'a machine is its owner\'s');
++ });
++
++ await check('the reading is stored again when it changes, or after a minute, never on every beat', async () => {
++ const first = (await rowOf(mine.deviceId)).fleet_at;
++ mine.beat({ fleet: [{ agent: 'codex', installed: true, signed_in: false }] });
++ await poll(async () => { const r = await rowOf(mine.deviceId); const f = list(r.fleet); return f.length === 1 && f[0].signed_in === false ? r : null; });
++ const changed = (await rowOf(mine.deviceId)).fleet_at;
++ assert.ok(at(changed) >= at(first));
++
++ // The same reading on the next beat is not another write...
++ mine.beat({ fleet: [{ agent: 'codex', installed: true, signed_in: false }] });
++ await t.wait(300);
++ assert.strictEqual(at((await rowOf(mine.deviceId)).fleet_at), at(changed));
++ // ...until a minute has passed, so a machine that keeps beating keeps fleet_at current (and one that stops
++ // keeps its reading with an age that says so).
++ t.clock.offset += 61_000;
++ mine.beat({ fleet: [{ agent: 'codex', installed: true, signed_in: false }] });
++ const again = await poll(async () => { const r = await rowOf(mine.deviceId); return at(r.fleet_at) > at(changed) ? r : null; });
++ assert.ok(again, 'a minute later the same reading is stored again with a fresh time');
++ mine.c.close();
++ });
++ } finally {
++ if (bare) bare.c.close();
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });