openvibers/openvibe.bot Commits
A queue command decided under a turn never follows that turn's halt (control epoch checked at send), and owner takeover pauses the turn and halts the robot first, resuming with its time kept (#155)
Changes
@@@ -99,7 +99,36 @@ Bot sends a `halt` of its own when a `queue` robot's turn ends — the turn ranbefore the next person's first command can pass the gate, so a held button, a drive still inside itsdeadline or firmware that keeps going on its own never carries into the next turn. Its `operator` is`{ "subject": null, "role": "system" }`; the device acks it like any command. A waiting person leaving--sends none.++sends none. The same halt goes out when the owner or an operator takes over a turn (below).++++**What the order guarantees, and what it does not.** Frames on one device connection arrive in the order++Bot sent them (one WebSocket), and OpenVibe.Node handles them in that order. Two things follow:++- **Nothing decided under a turn follows that turn's halt.** Bot ends a turn and sends its halt in one++ synchronous step that also moves the robot's control epoch. A queue driver's command whose gate decision++ began before that step is refused (`bot.not_your_turn`) at the moment it would be sent, with no await between++ the check and the send. So on the wire, everything authorised under turn N precedes N's halt.++- **Lateness is bounded by the deadline.** Every motion command carries `deadline_ms` on Bot's clock, at++ most the robot's `max_command_ms` away. Node compares it with its estimate of Bot's clock on arrival, nacks++ a late one (`expired`) before any driver sees it, and gives a driver only the time left.++++Across a reconnect nothing is replayed. Bot never queues commands for an offline device ("commands are never++queued"), `seq` starts again on each connection, and the device sees only server-minted command ids (Node++answers a repeated id from its dedup cache instead of running it again). A command in flight when the++socket drops is lost, and its operator gets `expired`.++++Not carried to a driver today: the turn epoch, Bot's `seq` or `ts`. A plugin sees the command id, kind,++value, the time left and `operator { subject, role }`, so it can tell turns apart only by the operator's++subject. A per-turn epoch in the command frame, checked by Node and passed to plugins, is the planned next++step.++++**Owner takeover.** When the owner or an operator sends a command to a `queue` robot while someone holds++the turn:++- Bot pauses that turn: the turn's clock stops.++- Bot sends its halt (audited `{ reason: "owner_takeover", turn_of }`), and the owner's or operator's command++ goes after it.++- The paused holder's commands are refused `bot.owner_control`, and waiting people stay in line.++- The turn resumes 15 s after their last command, or at once when they send `give_back` on `/control`. Its end++ moves on by the paused time.`status` of a Network-paired machine also says what it is: `device_kind` (`onboard` \| `bridge` \|`server`), `drivers` (an array of strings), `capabilities` (an object) and `agent_version` (a string of at@@@ -337,6 +366,7 @@ needs an explicit owner or operator role — a no-role caller is refused `bot.no| `command` | `id, kind, value, ms?` | the same `kind`s as the device; `ms` requests a deadline (capped by `max_command_ms`) || `estop` | — | latch the e-stop (owner or operator) || `estop_clear` | — | clear the latched e-stop (**owner only**) |++| `give_back` | — | owner or operator: hand back a queue turn your driving paused (§1 Owner takeover); it resumes at once with its time kept |### Server → client@@@ -348,10 +378,12 @@ needs an explicit owner or operator role — a no-role caller is refused `bot.no| `error` | `code, detail` | a frame or join the server refused |`robot_state.queue` (only for a `queue` robot) is--`{ robot_id, subject, active, turn_ends_at, position, budget, used }` — `subject` is the reader's own,++`{ robot_id, subject, active, turn_ends_at, paused, position, budget, used }` — `subject` is the reader's own,never another person's, so a waiting person's `position` is 1-based (0 while they hold the turn) and nomember ever learns the active driver's subject. When a turn ends Bot halts the robot before the next--turn starts (§1).++turn starts (§1). `paused` is `null`, or `{ by: "owner" | "operator", since, resumes_after_idle_ms }` while an++owner or operator has taken the turn over (§1). The client message `give_back` (owner or operator)++resumes it at once.### Examples@@@ -399,7 +431,8 @@ turn starts (§1).Refusal codes (the gate, in order): `bot.robot_not_found`, `bot.unknown_command`, `bot.command_not_allowed`(the profile takes no such kind), `bot.estop_latched`, `bot.not_an_operator`, `bot.sign_in`,`bot.read_only`, `bot.command_not_allowed` (the role's allowlist), `bot.device_offline`, `bot.cooldown`,--`bot.not_your_turn`, `bot.turn_budget`, `bot.unknown_actuator`, `bot.invalid_input`, `bot.text_too_long`.++`bot.not_your_turn`, `bot.owner_control` (the owner or an operator has taken the turn over), `bot.turn_budget`,++`bot.unknown_actuator`, `bot.invalid_input`, `bot.text_too_long`.A `command` whose `id` is still in flight (the first one has not been acked) is answered `refused``bot.command_pending` rather than a second device command or a silent wait.`halt` is never refused for the e-stop, the allowlist, a cooldown or the turn budget; it still needs a@@@ -507,7 +540,8 @@ the panel's owner-only forms `POST /robots/:id/operators` (body `username`, `rolEvery command — allowed or refused — is written to `command_audit` (robot, device, the operatorprincipal and its kind, role, kind, the clamped value summary, result `ack|nack|refused|expired`,reason, latency, time), kept 30 days. Bot's own halt at the end of a queue turn (§1) is a row with--`operator_kind` `system`, no subject and `value` `{ reason: "turn_ended" | "turn_left", turn_of }`. Changing a robot's streaming toggles writes one row per real change++`operator_kind` `system`, no subject and `value` `{ reason: "turn_ended" | "turn_left" | "owner_takeover", turn_of }`; a++queue command refused because its turn ended while the gate decided is a `refused` row with reason `bot.turn_ended`. Changing a robot's streaming toggles writes one row per real change(`kind` `streaming.media` or `streaming.live`, `value` `{ on }`, role `owner`, result `ack`); anidempotent repeat writes none. The outbox publishes `bot.robot.online`, `bot.robot.offline`,`bot.estop.set`, `bot.estop.cleared` and `bot.command.refused` — small payloads of ids, kinds andnnew file mode 100644@@@ -0,0 +1,7 @@++-- phase: expand++-- Owner takeover (a LOOI driver's reviewers asked for it, 2026-10-24): an owner or operator who drives a queue robot while++-- someone holds the turn pauses that turn, and Bot halts the robot before their command. paused_at is when, paused_by++-- the role that took over (owner | operator). The turn's clock stops while paused: on resuming, turn_ends_at moves on++-- by the paused time (server/domain/index.js currentTurn).++ALTER TABLE robot_queue ADD COLUMN IF NOT EXISTS paused_at timestamptz;++ALTER TABLE robot_queue ADD COLUMN IF NOT EXISTS paused_by text;@@@ -273,6 +273,7 @@let allowed = JSON.parse(main.dataset.allowed || '[]');let ws = null;let joined = false;++ let myRole = null; // the role the joined frame gave this panel (owner, operator, viewer, queue)let retry = 0;let gone = false; // refused for good (no access): no reconnect@@@ -794,12 +795,36 @@const turnMetaEl = $('[data-turn-meta]');let turnTimer = null;const stopTurnTimer = () => { if (turnTimer) { clearInterval(turnTimer); turnTimer = null; } };-- /** Paint the queue view from a state frame's `queue`: the active turn's countdown, a place in line, or hidden. */++ const turnGiveEl = $('[data-turn-give]');++ const turnLeaveEl = $('[data-turn-leave]');++ if (turnGiveEl) turnGiveEl.addEventListener('click', () => raw({ type: 'give_back' }));++ /**++ * Paint the queue view from a state frame's `queue`: the active turn's countdown, a place in line, a turn paused by++ * an owner takeover (the holder's time is kept; an owner or operator sees it with "Give back now"), or hidden.++ */function paintTurn(queue) {stopTurnTimer();if (!turnEl) return;-- if (!queue || (!queue.active && !(queue.position > 0))) { turnEl.hidden = true; return; }++ const controller = myRole === 'owner' || myRole === 'operator';++ const paused = queue && queue.paused;++ if (turnGiveEl) turnGiveEl.hidden = !(paused && controller);++ if (turnLeaveEl) turnLeaveEl.hidden = !!(controller && !(queue && queue.active));++ if (!queue || (!queue.active && !(queue.position > 0) && !(paused && controller))) { turnEl.hidden = true; return; }turnEl.hidden = false;++ if (paused) {++ const idle = Math.round((paused.resumes_after_idle_ms || 15000) / 1000);++ if (controller && !queue.active) {++ turnStateEl.textContent = 'You have control';++ turnMetaEl.textContent = `The queue's turn is paused and keeps its time. It resumes ${idle}s after your last command.`;++ } else if (queue.active) {++ turnStateEl.textContent = 'Your turn is paused';++ turnMetaEl.textContent = `The ${paused.by === 'operator' ? 'operator' : 'owner'} is driving. Your time is kept and resumes when they stop.`;++ } else {++ turnStateEl.textContent = `You're #${queue.position} in line`;++ turnMetaEl.textContent = `Paused while the ${paused.by === 'operator' ? 'operator' : 'owner'} drives`;++ }++ return;++ }if (queue.active) {const ends = queue.turn_ends_at ? new Date(queue.turn_ends_at).getTime() : null;const paintLeft = () => {@@@ -822,6 +847,7 @@if (m.ticket) ticket = m.ticket;if (driving) {const label = $('[data-role-label]');++ myRole = m.role || null;if (label && m.role) label.textContent = { owner: 'Owner', operator: 'Operator', viewer: 'Viewer', queue: 'In the queue' }[m.role] || m.role;for (const el of $$('[data-estop]')) el.hidden = !(m.role === 'owner' || m.role === 'operator');}@@@ -849,12 +849,57 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =// robot (server/realtime.js), so nothing the last driver sent carries on into the next person's turn.const handoffListeners = new Set();const onHandoff = (fn) => { handoffListeners.add(fn); return () => handoffListeners.delete(fn); };-- const handedOff = (robotId, from, reason) => { for (const fn of handoffListeners) { try { fn(robotId, { from, reason }); } catch { /* a listener never breaks the caller */ } } };++ // A robot's control epoch: one more each time a turn ends or its holder leaves, bumped in the same synchronous step++ // that sends the handoff halt (the listeners). prepare() notes it before its first await; the hub sends a queue++ // driver's command only if it is unchanged then, so a command decided under a turn never reaches the robot after++ // that turn's halt (the race: its gate read the turn, the sweep ended the turn and halted the robot during the gate's++ // later awaits, and the command followed the halt). In memory: Bot is one process.++ const epochs = new Map();++ const epochOf = (robotId) => epochs.get(robotId) || 0;++ const handedOff = (robotId, from, reason) => {++ epochs.set(robotId, epochOf(robotId) + 1);++ if (epochs.size > 10000) epochs.delete(epochs.keys().next().value);++ for (const fn of handoffListeners) { try { fn(robotId, { from, reason }); } catch { /* a listener never breaks the caller */ } }++ };++ // Owner takeover: an owner or operator driving a queue robot while someone holds the turn pauses the turn (its clock++ // stops) and halts the robot before their command, through the handoff path (reason owner_takeover: the epoch moves,++ // so the holder's commands still in the gate never follow). The turn resumes OWNER_IDLE_MS after their last command,++ // or at once when they give it back (giveBack), with the paused time added to its end. In memory, like the epochs:++ // after a restart a paused turn resumes at the next sweep.++ const OWNER_IDLE_MS = 15000;++ const ownerSeen = new Map(); // robotId → { at, subject, role }++ const resumeListeners = new Set();++ const onResume = (fn) => { resumeListeners.add(fn); return () => resumeListeners.delete(fn); };++ async function takeOver(robotId, subject, role) {++ ownerSeen.set(robotId, { at: now(), subject, role });++ if (ownerSeen.size > 10000) ownerSeen.delete(ownerSeen.keys().next().value);++ const paused = await db.maybe(`UPDATE robot_queue SET paused_at = $2, paused_by = $3 WHERE robot_id = $1 AND state = 'active' AND paused_at IS NULL RETURNING subject`,++ [robotId, iso(now()), role]);++ if (paused) handedOff(robotId, paused.subject, 'owner_takeover');++ return !!paused;++ }++ async function giveBack(robotId) {++ ownerSeen.delete(robotId);++ return await currentTurn(robotId);++ }/** The active turn for a robot, expiring it and promoting the oldest waiting row when it has ended. */async function currentTurn(robotId, q = db) {const robot = await q.maybe('SELECT * FROM robots WHERE id = $1', [robotId]);if (!robot) return null;let active = await q.maybe(`SELECT * FROM robot_queue WHERE robot_id = $1 AND state = 'active'`, [robotId]);++ if (active && active.paused_at) {++ // Paused by an owner or operator: it waits, clock stopped, until they have been idle OWNER_IDLE_MS or gave it back.++ const seen = ownerSeen.get(robotId);++ if (seen && now() - seen.at < OWNER_IDLE_MS) return active;++ const pausedMs = Math.max(0, now() - new Date(active.paused_at).getTime());++ const resumed = await q.maybe(`UPDATE robot_queue SET paused_at = NULL, paused_by = NULL, turn_ends_at = turn_ends_at + make_interval(secs => $3::double precision / 1000)++ WHERE robot_id = $1 AND subject = $2 AND state = 'active' AND paused_at IS NOT NULL RETURNING *`, [robotId, active.subject, pausedMs]);++ if (resumed) {++ ownerSeen.delete(robotId);++ for (const fn of resumeListeners) { try { fn(robotId, { subject: resumed.subject }); } catch { /* a listener never breaks the caller */ } }++ }++ active = resumed || await q.maybe(`SELECT * FROM robot_queue WHERE robot_id = $1 AND state = 'active'`, [robotId]);++ }if (active && new Date(active.turn_ends_at).getTime() > now()) return active;if (active) {// Only the caller that ends the turn reports it (two at once both read it active).@@@ -900,6 +945,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =return {robot_id: robotId, subject: subject || null, active: !!(active && active.subject === subject),turn_ends_at: active ? iso(new Date(active.turn_ends_at).getTime()) : null,++ // An owner or operator has taken over: the turn is paused (its clock stopped) and resumes after they stop.++ paused: active && active.paused_at ? { by: active.paused_by || 'owner', since: iso(new Date(active.paused_at).getTime()), resumes_after_idle_ms: OWNER_IDLE_MS } : null,position: active && active.subject === subject ? 0 : position,budget: turnBudget(robot),used: active && active.subject === subject ? active.commands_used : 0,@@@ -908,16 +955,23 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =async function consumeTurn(robotId, subject) {const active = await currentTurn(robotId);if (!active || active.subject !== subject) return { ok: false, code: 'bot.not_your_turn' };++ if (active.paused_at) return { ok: false, code: 'bot.owner_control' };const robot = await getRobot(robotId);if (active.commands_used >= turnBudget(robot)) return { ok: false, code: 'bot.turn_budget' };-- await db.query(`UPDATE robot_queue SET commands_used = commands_used + 1 WHERE robot_id = $1 AND subject = $2`, [robotId, subject]);-- return { ok: true };++ // Only a turn that is still active spends (the sweep may have ended it since it was read).++ const spent = await db.maybe(`UPDATE robot_queue SET commands_used = commands_used + 1 WHERE robot_id = $1 AND subject = $2 AND state = 'active' RETURNING 1 AS ok`, [robotId, subject]);++ return spent ? { ok: true } : { ok: false, code: 'bot.not_your_turn' };}/** Expire ended turns and promote the next waiting person; returns the robots that changed. */async function sweepQueues() {const changed = new Set();-- const ended = await db.many(`SELECT DISTINCT robot_id FROM robot_queue WHERE state = 'active' AND turn_ends_at <= $1`, [iso(now())]);-- for (const r of ended) { await currentTurn(r.robot_id); changed.add(r.robot_id); }++ const ended = await db.many(`SELECT DISTINCT robot_id FROM robot_queue WHERE state = 'active' AND (turn_ends_at <= $1 OR paused_at IS NOT NULL)`, [iso(now())]);++ for (const r of ended) {++ const before = await db.maybe(`SELECT subject, paused_at FROM robot_queue WHERE robot_id = $1 AND state = 'active'`, [r.robot_id]);++ const after = await currentTurn(r.robot_id);++ // A turn still paused and still the same holder changed nothing a panel shows.++ if (!(before && before.paused_at && after && after.paused_at && after.subject === before.subject)) changed.add(r.robot_id);++ }const idle = await db.many(`SELECT DISTINCT q.robot_id FROM robot_queue q WHERE q.state = 'waiting'AND NOT EXISTS (SELECT 1 FROM robot_queue a WHERE a.robot_id = q.robot_id AND a.state = 'active')`);for (const r of idle) { await currentTurn(r.robot_id); changed.add(r.robot_id); }@@@ -1107,6 +1161,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =* { ok:false, code, reason, robot?, role? }. `online` is the live socket state the hub knows.*/async function prepare({ robotId, principal, kind, value = {}, online = false, requestedMs = null }) {++ const epoch = epochOf(robotId); // before the first await: see epochs aboveconst robot = await getRobot(robotId);if (!robot) return { ok: false, code: 'bot.robot_not_found', reason: 'no such robot' };const profileRow = await getProfile(db, robot.profile_id, robot.profile_version);@@@ -1144,15 +1199,20 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =return { ok: false, code: 'bot.cooldown', reason: `wait ${eff.cooldownMs} ms between ${kind} commands`, robot, role };}}++ const PAUSED = 'the owner has taken control: your turn is paused and keeps its time';if (role === 'queue') {// A halt needs the turn (a waiting person may not stop the driver) but spends none of its budget.if (halt || release || repeatedHold) {const turn = await currentTurn(robotId);if (!turn || turn.subject !== subject) return { ok: false, code: 'bot.not_your_turn', reason: 'it is not your turn', robot, role };++ if (turn.paused_at) return { ok: false, code: 'bot.owner_control', reason: PAUSED, robot, role };} else {const budget = await consumeTurn(robotId, subject);-- if (!budget.ok) return { ok: false, code: budget.code, reason: budget.code === 'bot.turn_budget' ? 'your turn budget is spent' : 'it is not your turn', robot, role };++ if (!budget.ok) return { ok: false, code: budget.code, reason: budget.code === 'bot.turn_budget' ? 'your turn budget is spent' : budget.code === 'bot.owner_control' ? PAUSED : 'it is not your turn', robot, role };}++ } else if ((role === 'owner' || role === 'operator') && robot.access_policy === 'queue' && subject) {++ // Owner takeover: the holder's turn pauses and the robot halts before this command goes (takeOver).++ await takeOver(robotId, subject, role);}let built;try { built = buildValue(kind, value, eff, profile); } catch (e) { return { ok: false, code: e.code || 'bot.invalid_input', reason: e.detail || e.message, robot, role }; }@@@ -1171,7 +1231,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =if (activeKey && built.state === 'down') activeButtons.set(activeKey, deadlineMs);if (activeKey && built.state === 'up') activeButtons.delete(activeKey);if (activeButtons.size > 4096) activeButtons.delete(activeButtons.keys().next().value);-- return { ok: true, robot, profile, role, kind, value: built, deadlineMs, limits: eff };++ return { ok: true, robot, profile, role, kind, value: built, deadlineMs, limits: eff, epoch };}return {@@@ -1187,7 +1247,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =streaming: { get: streaming, set: setStreaming },video: { live: liveVideo },estop: { set: setEstop, clear: clearEstop },-- queue: { join: joinQueue, leave: leaveQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues, onHandoff },++ queue: { join: joinQueue, leave: leaveQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues, onHandoff, onResume, epoch: epochOf, giveBack, OWNER_IDLE_MS },audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },control: { prepare, allowedFor, effectiveLimits, deviceLimits, buildValue, DEFAULT_ALLOW, KINDS },};@@@ -492,6 +492,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datcase 'command': return onOperatorCommand(conn, msg);case 'estop': return onOperatorEstop(conn, true);case 'estop_clear': return onOperatorEstop(conn, false);++ case 'give_back': return onGiveBack(conn);default: return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);}}@@@ -552,6 +553,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datawait domain.audit.record({ robotId, deviceId: device ? device.device.id : null, subject: conn.subject, operatorKind: conn.principal.kind, role: decision.role || conn.role, kind: msg.kind, value: msg.value || {}, result: 'refused', reason: decision.code });return sendFrame(conn, 'command_result', { id, result: 'refused', code: decision.code, reason: decision.reason });}++ // A queue turn that ended while the gate was deciding (domain epochs): its halt is already out, so this command++ // would follow it into the next turn. Checked here, with no await before the send.++ if (decision.role === 'queue' && domain.queue.epoch(robotId) !== decision.epoch) {++ await domain.audit.record({ robotId, deviceId: device ? device.device.id : null, subject: conn.subject, operatorKind: conn.principal.kind, role: decision.role, kind: decision.kind, value: decision.value, result: 'refused', reason: 'bot.turn_ended' });++ return sendFrame(conn, 'command_result', { id, result: 'refused', code: 'bot.not_your_turn', reason: 'your turn ended before this command could be sent' });++ }const deviceCmdId = prefixedId('cmd', now());const frame = { v: 1, type: 'command', seq: nextSeq(device), ts: now(), id: deviceCmdId, ref: id, kind: decision.kind, value: decision.value, deadline_ms: decision.deadlineMs, operator: { subject: conn.subject, role: decision.role }, robot_id: robotId };if (!send(device.ws, frame)) {@@@ -723,6 +730,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat}}++ /** `give_back`: an owner or operator who took over a queue turn hands it back now (it resumes with its time). */++ async function onGiveBack(conn) {++ if (!conn.robotId) return sendError(conn, 'bot.not_joined', 'join a robot first');++ const role = await domain.members.roleOf(conn.robotId, conn.subject);++ if (!['owner', 'operator'].includes(role)) return sendError(conn, 'bot.forbidden', 'only the owner or an operator takes over and gives back');++ await domain.queue.giveBack(conn.robotId);++ broadcast(conn.robotId);++ return undefined;++ }++async function onOperatorEstop(conn, latched) {if (!conn.robotId) return sendError(conn, 'bot.not_joined', 'join a robot first');const robotId = conn.robotId;@@@ -933,7 +950,14 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datfor (const t of timers) if (t.unref) t.unref();return {-- bindDomain(d) { domain = d; d.queue.onHandoff(haltForTurn); },++ bindDomain(d) {++ domain = d;++ d.queue.onHandoff(haltForTurn);++ // A turn that ends, is left or pauses for an owner takeover, and one that resumes, repaints every panel now++ // (after the halt has gone), not at the next sweep.++ d.queue.onHandoff((robotId) => setImmediate(() => broadcast(robotId)));++ if (d.queue.onResume) d.queue.onResume((robotId) => setImmediate(() => broadcast(robotId)));++ },bindJobs(j) { jobs = j; },handleUpgrade, attachSim, detachSim,/** A one-time ticket for the embedded panel's Drive (POST /panel/:id/ticket), or null. */@@@ -429,13 +429,16 @@ ${(people || []).map(row).join('\n')}}/**-- * The turn strip a signed-in non-member sees on a `queue` robot's panel: the active turn's countdown or their-- * place in line, and a plain leave form that works without JavaScript (public/panel.js unhides it and paints-- * the state). Hidden until a state frame says there is a queue view for this person.++ * The turn strip on a `queue` robot's panel. A signed-in non-member sees the active turn's countdown or their place in++ * line, with a plain leave form that works without JavaScript. The owner and operators see it only while their own++ * driving has paused someone's turn (owner takeover), with "Give back now". public/panel.js unhides and paints it from++ * the state frames; it stays hidden until one says there is something to show this person.*/function turnStrip(robot, role) {-- if (robot.access_policy !== 'queue' || role !== 'queue') return '';-- return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span><form method="post" action="/robots/${esc(robot.id)}/queue/leave" data-turn-leave><button type="submit" class="quiet">Leave the queue</button></form></div>`;++ if (robot.access_policy !== 'queue' || !['queue', 'owner', 'operator'].includes(role)) return '';++ const leave = role === 'queue' ? `<form method="post" action="/robots/${esc(robot.id)}/queue/leave" data-turn-leave><button type="submit" class="quiet">Leave the queue</button></form>` : '';++ const give = role === 'queue' ? '' : '<button type="button" class="quiet" data-turn-give hidden>Give back now</button>';++ return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span>${leave}${give}</div>`;}function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null }) {@@@ -292,6 +292,103 @@ const plugins = require('./helpers/plugins');c.close(); d.close();});++ await check('a command whose turn ends while the gate decides is refused, never sent after the handoff halt', async () => {++ // The race a LOOI driver's reviewers asked about (A2): the gate read carol's turn, the turn ended (the sweep, or++ // she left) during the gate's later awaits and the halt went out, and her command followed the halt into dave's++ // turn. The control epoch moves with the halt; a queue command decided under the old one is refused.++ const { robot, pairing } = await t.robot(alex, { access_policy: 'queue', limits: { turn_ms: 60000, turn_budget: 5 } });++ const dev = await deviceFor(robot.id, pairing.code);++ const c = await op(carol);++ await joinResult(c, robot.id);++ const d = await op(dave);++ await joinResult(d, robot.id);++ const real = t.domain.control.prepare;++ t.domain.control.prepare = async (args) => {++ const decision = await real(args);++ if (args.principal && args.principal.subject === carol.subject) await t.domain.queue.leave(robot.id, carol.subject);++ return decision;++ };++ try {++ cmd(c, { id: 'r1', kind: 'drive', value: { throttle: 0.3 } });++ const r = await result(c, 'r1');++ assert.deepStrictEqual([r.result, r.code], ['refused', 'bot.not_your_turn'], r.reason);++ } finally {++ t.domain.control.prepare = real;++ }++ const halt = await dev.waitFor((m) => m.type === 'command' && m.kind === 'halt');++ dev.send({ type: 'ack', id: halt.id });++ await t.wait(50);++ assert.ok(!dev.messages.some((m) => m.type === 'command' && m.ref === 'r1'), 'the ended turn\'s command never reached the robot');++ cmd(d, { id: 'r2', kind: 'drive', value: { throttle: 0.3 } });++ const r2 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'r2');++ dev.send({ type: 'ack', id: r2.id });++ await result(d, 'r2');++ assert.deepStrictEqual(dev.messages.filter((m) => m.type === 'command').map((m) => (m.kind === 'halt' ? 'halt' : m.ref)), ['halt', 'r2']);++ const audit = await t.domain.audit.list(robot.id, { limit: 5 });++ assert.ok(audit.some((x) => x.result === 'refused' && x.reason === 'bot.turn_ended'), 'the refusal is audited');++ c.close(); d.close();++ });++++ await check('owner takeover: the owner driving pauses the turn and halts the robot first; the turn keeps its time and resumes on give-back or after the owner is idle', async () => {++ const { robot, pairing } = await t.robot(alex, { access_policy: 'queue', limits: { turn_ms: 60000, turn_budget: 20 } });++ const dev = await deviceFor(robot.id, pairing.code);++ const c = await op(carol);++ await joinResult(c, robot.id);++ const a = await op(alex);++ assert.strictEqual((await joinResult(a, robot.id)).role, 'owner');++ cmd(c, { id: 'o1', kind: 'drive', value: { throttle: 0.3 } });++ const o1 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'o1');++ dev.send({ type: 'ack', id: o1.id });++ await result(c, 'o1');++ const before = await t.domain.queue.currentTurn(robot.id);++ cmd(a, { id: 'o2', kind: 'drive', value: { throttle: 0.5 } });++ const o2 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'o2');++ dev.send({ type: 'ack', id: o2.id });++ await result(a, 'o2');++ const cmds = dev.messages.filter((m) => m.type === 'command');++ assert.deepStrictEqual(cmds.map((m) => (m.kind === 'halt' ? 'halt' : m.ref)), ['o1', 'halt', 'o2'], 'the robot halts before the owner drives');++ const halt = cmds.find((m) => m.kind === 'halt');++ dev.send({ type: 'ack', id: halt.id });++ assert.deepStrictEqual(halt.operator, { subject: null, role: 'system' });++ await t.wait(30);++ const row = (await t.domain.audit.list(robot.id, { limit: 6 })).find((x) => x.kind === 'halt');++ assert.deepStrictEqual(row.value, { reason: 'owner_takeover', turn_of: carol.subject }, 'audited as an owner takeover of carol\'s turn');++ let q = await t.domain.queue.state(robot.id, carol.subject);++ assert.strictEqual(q.active, true);++ assert.strictEqual(q.paused.by, 'owner');++ cmd(c, { id: 'o3', kind: 'drive', value: { throttle: 0.3 } });++ const r3 = await result(c, 'o3');++ assert.deepStrictEqual([r3.result, r3.code], ['refused', 'bot.owner_control'], 'the paused holder does not drive');++ // The owner gives it back: the turn resumes at once, its end moved on by the paused time.++ await t.wait(40);++ a.send({ type: 'give_back' });++ for (let i = 0; i < 50 && (await t.domain.queue.state(robot.id, carol.subject)).paused; i++) await t.wait(20);++ q = await t.domain.queue.state(robot.id, carol.subject);++ assert.strictEqual(q.paused, null, 'given back');++ assert.ok(new Date(q.turn_ends_at).getTime() > new Date(before.turn_ends_at).getTime(), 'the paused time is added to the turn');++ cmd(c, { id: 'o4', kind: 'drive', value: { throttle: 0.3 } });++ const o4 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'o4');++ dev.send({ type: 'ack', id: o4.id });++ assert.strictEqual((await result(c, 'o4')).result, 'ack', 'carol drives again');++ // Taken over again, then the owner goes idle: the sweep resumes the turn after OWNER_IDLE_MS.++ cmd(a, { id: 'o5', kind: 'drive', value: { throttle: 0.5 } });++ const o5 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'o5');++ dev.send({ type: 'ack', id: o5.id });++ await result(a, 'o5');++ assert.ok((await t.domain.queue.state(robot.id, carol.subject)).paused, 'paused again');++ t.clock.offset += t.domain.queue.OWNER_IDLE_MS + 1000;++ try {++ await t.domain.queue.sweep();++ assert.strictEqual((await t.domain.queue.state(robot.id, carol.subject)).paused, null, 'resumed once the owner is idle');++ } finally {++ t.clock.offset -= t.domain.queue.OWNER_IDLE_MS + 1000;++ }++ // Someone not allowed to take over cannot give back.++ c.send({ type: 'give_back' });++ assert.strictEqual((await c.waitFor((m) => m.type === 'error' && m.code === 'bot.forbidden')).code, 'bot.forbidden');++ c.close(); a.close();++ });++await check('a service token with bot.robot.control acts for a viewer and is held to the viewer role', async () => {const { robot, pairing } = await t.robot(alex);const viewer = t.network.newUser('viewer');@@@ -148,13 +148,14 @@ function identityStub() {assert.ok(back.includes('<b>Carol</b><small>@carol · Viewer</small>'));});-- await check('a queue robot: a signed-in non-member gets the turn strip; the owner does not', async () => {++ await check('a queue robot: a signed-in non-member gets the turn strip; the owner gets only its give-back form', async () => {const { robot: q } = await t.robot(alex, { name: 'Queue', access_policy: 'queue' });const html = await (await get(`/panel/${q.id}`, bob)).text();assert.ok(html.includes('<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span>'));assert.ok(html.includes(`<form method="post" action="/robots/${q.id}/queue/leave" data-turn-leave>`));const owner = await (await get(`/panel/${q.id}`, alex)).text();-- assert.ok(!owner.includes('data-turn'), 'the owner sees no leave strip');++ assert.ok(!owner.includes('data-turn-leave'), 'the owner sees no leave strip');++ assert.ok(owner.includes('<button type="button" class="quiet" data-turn-give hidden>Give back now</button>'), 'the owner\'s strip, hidden until a takeover pauses a turn, only gives back');assert.ok(owner.includes('Anyone signed in can also take a turn'), 'the queue note is on the owner card');// Bob joins on /control (the queue's own door) and becomes the active turn; Carol waits.
@@ -99,7 +99,36 @@ Bot sends a `halt` of its own when a `queue` robot's turn ends — the turn ran
@@ -99,7 +99,36 @@ Bot sends a `halt` of its own when a `queue` robot's turn ends — the turn ran
before the next person's first command can pass the gate, so a held button, a drive still inside its
before the next person's first command can pass the gate, so a held button, a drive still inside its
deadline or firmware that keeps going on its own never carries into the next turn. Its `operator` is
deadline or firmware that keeps going on its own never carries into the next turn. Its `operator` is
`{ "subject": null, "role": "system" }`; the device acks it like any command. A waiting person leaving
`{ "subject": null, "role": "system" }`; the device acks it like any command. A waiting person leaving
-sends none.
+sends none. The same halt goes out when the owner or an operator takes over a turn (below).
+
+**What the order guarantees, and what it does not.** Frames on one device connection arrive in the order
+Bot sent them (one WebSocket), and OpenVibe.Node handles them in that order. Two things follow:
+- **Nothing decided under a turn follows that turn's halt.** Bot ends a turn and sends its halt in one
+ synchronous step that also moves the robot's control epoch. A queue driver's command whose gate decision
+ began before that step is refused (`bot.not_your_turn`) at the moment it would be sent, with no await between
+ the check and the send. So on the wire, everything authorised under turn N precedes N's halt.
+- **Lateness is bounded by the deadline.** Every motion command carries `deadline_ms` on Bot's clock, at
+ most the robot's `max_command_ms` away. Node compares it with its estimate of Bot's clock on arrival, nacks
+ a late one (`expired`) before any driver sees it, and gives a driver only the time left.
+
+Across a reconnect nothing is replayed. Bot never queues commands for an offline device ("commands are never
+queued"), `seq` starts again on each connection, and the device sees only server-minted command ids (Node
+answers a repeated id from its dedup cache instead of running it again). A command in flight when the
+socket drops is lost, and its operator gets `expired`.
+
+Not carried to a driver today: the turn epoch, Bot's `seq` or `ts`. A plugin sees the command id, kind,
+value, the time left and `operator { subject, role }`, so it can tell turns apart only by the operator's
+subject. A per-turn epoch in the command frame, checked by Node and passed to plugins, is the planned next
+step.
+
+**Owner takeover.** When the owner or an operator sends a command to a `queue` robot while someone holds
+the turn:
+- Bot pauses that turn: the turn's clock stops.
+- Bot sends its halt (audited `{ reason: "owner_takeover", turn_of }`), and the owner's or operator's command
+ goes after it.
+- The paused holder's commands are refused `bot.owner_control`, and waiting people stay in line.
+- The turn resumes 15 s after their last command, or at once when they send `give_back` on `/control`. Its end
+ moves on by the paused time.
`status` of a Network-paired machine also says what it is: `device_kind` (`onboard` \| `bridge` \|
`status` of a Network-paired machine also says what it is: `device_kind` (`onboard` \| `bridge` \|
`server`), `drivers` (an array of strings), `capabilities` (an object) and `agent_version` (a string of at
`server`), `drivers` (an array of strings), `capabilities` (an object) and `agent_version` (a string of at
@@ -337,6 +366,7 @@ needs an explicit owner or operator role — a no-role caller is refused `bot.no
@@ -337,6 +366,7 @@ needs an explicit owner or operator role — a no-role caller is refused `bot.no
| `command` | `id, kind, value, ms?` | the same `kind`s as the device; `ms` requests a deadline (capped by `max_command_ms`) |
| `command` | `id, kind, value, ms?` | the same `kind`s as the device; `ms` requests a deadline (capped by `max_command_ms`) |
| `estop` | — | latch the e-stop (owner or operator) |
| `estop` | — | latch the e-stop (owner or operator) |
| `estop_clear` | — | clear the latched e-stop (**owner only**) |
| `estop_clear` | — | clear the latched e-stop (**owner only**) |
+| `give_back` | — | owner or operator: hand back a queue turn your driving paused (§1 Owner takeover); it resumes at once with its time kept |
### Server → client
### Server → client
@@ -348,10 +378,12 @@ needs an explicit owner or operator role — a no-role caller is refused `bot.no
@@ -348,10 +378,12 @@ needs an explicit owner or operator role — a no-role caller is refused `bot.no
| `error` | `code, detail` | a frame or join the server refused |
| `error` | `code, detail` | a frame or join the server refused |
`robot_state.queue` (only for a `queue` robot) is
`robot_state.queue` (only for a `queue` robot) is
-`{ robot_id, subject, active, turn_ends_at, position, budget, used }` — `subject` is the reader's own,
+`{ robot_id, subject, active, turn_ends_at, paused, position, budget, used }` — `subject` is the reader's own,
never another person's, so a waiting person's `position` is 1-based (0 while they hold the turn) and no
never another person's, so a waiting person's `position` is 1-based (0 while they hold the turn) and no
member ever learns the active driver's subject. When a turn ends Bot halts the robot before the next
member ever learns the active driver's subject. When a turn ends Bot halts the robot before the next
-turn starts (§1).
+turn starts (§1). `paused` is `null`, or `{ by: "owner" | "operator", since, resumes_after_idle_ms }` while an
+owner or operator has taken the turn over (§1). The client message `give_back` (owner or operator)
+resumes it at once.
### Examples
### Examples
@@ -399,7 +431,8 @@ turn starts (§1).
@@ -399,7 +431,8 @@ turn starts (§1).
Refusal codes (the gate, in order): `bot.robot_not_found`, `bot.unknown_command`, `bot.command_not_allowed`
Refusal codes (the gate, in order): `bot.robot_not_found`, `bot.unknown_command`, `bot.command_not_allowed`
(the profile takes no such kind), `bot.estop_latched`, `bot.not_an_operator`, `bot.sign_in`,
(the profile takes no such kind), `bot.estop_latched`, `bot.not_an_operator`, `bot.sign_in`,
`bot.read_only`, `bot.command_not_allowed` (the role's allowlist), `bot.device_offline`, `bot.cooldown`,
`bot.read_only`, `bot.command_not_allowed` (the role's allowlist), `bot.device_offline`, `bot.cooldown`,
-`bot.not_your_turn`, `bot.turn_budget`, `bot.unknown_actuator`, `bot.invalid_input`, `bot.text_too_long`.
+`bot.not_your_turn`, `bot.owner_control` (the owner or an operator has taken the turn over), `bot.turn_budget`,
+`bot.unknown_actuator`, `bot.invalid_input`, `bot.text_too_long`.
A `command` whose `id` is still in flight (the first one has not been acked) is answered `refused`
A `command` whose `id` is still in flight (the first one has not been acked) is answered `refused`
`bot.command_pending` rather than a second device command or a silent wait.
`bot.command_pending` rather than a second device command or a silent wait.
`halt` is never refused for the e-stop, the allowlist, a cooldown or the turn budget; it still needs a
`halt` is never refused for the e-stop, the allowlist, a cooldown or the turn budget; it still needs a
@@ -507,7 +540,8 @@ the panel's owner-only forms `POST /robots/:id/operators` (body `username`, `rol
@@ -507,7 +540,8 @@ the panel's owner-only forms `POST /robots/:id/operators` (body `username`, `rol
Every command — allowed or refused — is written to `command_audit` (robot, device, the operator
Every command — allowed or refused — is written to `command_audit` (robot, device, the operator
principal and its kind, role, kind, the clamped value summary, result `ack|nack|refused|expired`,
principal and its kind, role, kind, the clamped value summary, result `ack|nack|refused|expired`,
reason, latency, time), kept 30 days. Bot's own halt at the end of a queue turn (§1) is a row with
reason, latency, time), kept 30 days. Bot's own halt at the end of a queue turn (§1) is a row with
-`operator_kind` `system`, no subject and `value` `{ reason: "turn_ended" | "turn_left", turn_of }`. Changing a robot's streaming toggles writes one row per real change
+`operator_kind` `system`, no subject and `value` `{ reason: "turn_ended" | "turn_left" | "owner_takeover", turn_of }`; a
+queue command refused because its turn ended while the gate decided is a `refused` row with reason `bot.turn_ended`. Changing a robot's streaming toggles writes one row per real change
(`kind` `streaming.media` or `streaming.live`, `value` `{ on }`, role `owner`, result `ack`); an
(`kind` `streaming.media` or `streaming.live`, `value` `{ on }`, role `owner`, result `ack`); an
idempotent repeat writes none. The outbox publishes `bot.robot.online`, `bot.robot.offline`,
idempotent repeat writes none. The outbox publishes `bot.robot.online`, `bot.robot.offline`,
`bot.estop.set`, `bot.estop.cleared` and `bot.command.refused` — small payloads of ids, kinds and
`bot.estop.set`, `bot.estop.cleared` and `bot.command.refused` — small payloads of ids, kinds and
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+-- phase: expand
+-- Owner takeover (a LOOI driver's reviewers asked for it, 2026-10-24): an owner or operator who drives a queue robot while
+-- someone holds the turn pauses that turn, and Bot halts the robot before their command. paused_at is when, paused_by
+-- the role that took over (owner | operator). The turn's clock stops while paused: on resuming, turn_ends_at moves on
+-- by the paused time (server/domain/index.js currentTurn).
+ALTER TABLE robot_queue ADD COLUMN IF NOT EXISTS paused_at timestamptz;
+ALTER TABLE robot_queue ADD COLUMN IF NOT EXISTS paused_by text;
@@ -273,6 +273,7 @@
@@ -273,6 +273,7 @@
let allowed = JSON.parse(main.dataset.allowed || '[]');
let allowed = JSON.parse(main.dataset.allowed || '[]');
let ws = null;
let ws = null;
let joined = false;
let joined = false;
+ let myRole = null; // the role the joined frame gave this panel (owner, operator, viewer, queue)
let retry = 0;
let retry = 0;
let gone = false; // refused for good (no access): no reconnect
let gone = false; // refused for good (no access): no reconnect
@@ -794,12 +795,36 @@
@@ -794,12 +795,36 @@
const turnMetaEl = $('[data-turn-meta]');
const turnMetaEl = $('[data-turn-meta]');
let turnTimer = null;
let turnTimer = null;
const stopTurnTimer = () => { if (turnTimer) { clearInterval(turnTimer); turnTimer = null; } };
const stopTurnTimer = () => { if (turnTimer) { clearInterval(turnTimer); turnTimer = null; } };
- /** Paint the queue view from a state frame's `queue`: the active turn's countdown, a place in line, or hidden. */
+ const turnGiveEl = $('[data-turn-give]');
+ const turnLeaveEl = $('[data-turn-leave]');
+ if (turnGiveEl) turnGiveEl.addEventListener('click', () => raw({ type: 'give_back' }));
+ /**
+ * Paint the queue view from a state frame's `queue`: the active turn's countdown, a place in line, a turn paused by
+ * an owner takeover (the holder's time is kept; an owner or operator sees it with "Give back now"), or hidden.
+ */
function paintTurn(queue) {
function paintTurn(queue) {
stopTurnTimer();
stopTurnTimer();
if (!turnEl) return;
if (!turnEl) return;
- if (!queue || (!queue.active && !(queue.position > 0))) { turnEl.hidden = true; return; }
+ const controller = myRole === 'owner' || myRole === 'operator';
+ const paused = queue && queue.paused;
+ if (turnGiveEl) turnGiveEl.hidden = !(paused && controller);
+ if (turnLeaveEl) turnLeaveEl.hidden = !!(controller && !(queue && queue.active));
+ if (!queue || (!queue.active && !(queue.position > 0) && !(paused && controller))) { turnEl.hidden = true; return; }
turnEl.hidden = false;
turnEl.hidden = false;
+ if (paused) {
+ const idle = Math.round((paused.resumes_after_idle_ms || 15000) / 1000);
+ if (controller && !queue.active) {
+ turnStateEl.textContent = 'You have control';
+ turnMetaEl.textContent = `The queue's turn is paused and keeps its time. It resumes ${idle}s after your last command.`;
+ } else if (queue.active) {
+ turnStateEl.textContent = 'Your turn is paused';
+ turnMetaEl.textContent = `The ${paused.by === 'operator' ? 'operator' : 'owner'} is driving. Your time is kept and resumes when they stop.`;
+ } else {
+ turnStateEl.textContent = `You're #${queue.position} in line`;
+ turnMetaEl.textContent = `Paused while the ${paused.by === 'operator' ? 'operator' : 'owner'} drives`;
+ }
+ return;
+ }
if (queue.active) {
if (queue.active) {
const ends = queue.turn_ends_at ? new Date(queue.turn_ends_at).getTime() : null;
const ends = queue.turn_ends_at ? new Date(queue.turn_ends_at).getTime() : null;
const paintLeft = () => {
const paintLeft = () => {
@@ -822,6 +847,7 @@
@@ -822,6 +847,7 @@
if (m.ticket) ticket = m.ticket;
if (m.ticket) ticket = m.ticket;
if (driving) {
if (driving) {
const label = $('[data-role-label]');
const label = $('[data-role-label]');
+ myRole = m.role || null;
if (label && m.role) label.textContent = { owner: 'Owner', operator: 'Operator', viewer: 'Viewer', queue: 'In the queue' }[m.role] || m.role;
if (label && m.role) label.textContent = { owner: 'Owner', operator: 'Operator', viewer: 'Viewer', queue: 'In the queue' }[m.role] || m.role;
for (const el of $$('[data-estop]')) el.hidden = !(m.role === 'owner' || m.role === 'operator');
for (const el of $$('[data-estop]')) el.hidden = !(m.role === 'owner' || m.role === 'operator');
}
}
@@ -849,12 +849,57 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -849,12 +849,57 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
// robot (server/realtime.js), so nothing the last driver sent carries on into the next person's turn.
// robot (server/realtime.js), so nothing the last driver sent carries on into the next person's turn.
const handoffListeners = new Set();
const handoffListeners = new Set();
const onHandoff = (fn) => { handoffListeners.add(fn); return () => handoffListeners.delete(fn); };
const onHandoff = (fn) => { handoffListeners.add(fn); return () => handoffListeners.delete(fn); };
- const handedOff = (robotId, from, reason) => { for (const fn of handoffListeners) { try { fn(robotId, { from, reason }); } catch { /* a listener never breaks the caller */ } } };
+ // A robot's control epoch: one more each time a turn ends or its holder leaves, bumped in the same synchronous step
+ // that sends the handoff halt (the listeners). prepare() notes it before its first await; the hub sends a queue
+ // driver's command only if it is unchanged then, so a command decided under a turn never reaches the robot after
+ // that turn's halt (the race: its gate read the turn, the sweep ended the turn and halted the robot during the gate's
+ // later awaits, and the command followed the halt). In memory: Bot is one process.
+ const epochs = new Map();
+ const epochOf = (robotId) => epochs.get(robotId) || 0;
+ const handedOff = (robotId, from, reason) => {
+ epochs.set(robotId, epochOf(robotId) + 1);
+ if (epochs.size > 10000) epochs.delete(epochs.keys().next().value);
+ for (const fn of handoffListeners) { try { fn(robotId, { from, reason }); } catch { /* a listener never breaks the caller */ } }
+ };
+ // Owner takeover: an owner or operator driving a queue robot while someone holds the turn pauses the turn (its clock
+ // stops) and halts the robot before their command, through the handoff path (reason owner_takeover: the epoch moves,
+ // so the holder's commands still in the gate never follow). The turn resumes OWNER_IDLE_MS after their last command,
+ // or at once when they give it back (giveBack), with the paused time added to its end. In memory, like the epochs:
+ // after a restart a paused turn resumes at the next sweep.
+ const OWNER_IDLE_MS = 15000;
+ const ownerSeen = new Map(); // robotId → { at, subject, role }
+ const resumeListeners = new Set();
+ const onResume = (fn) => { resumeListeners.add(fn); return () => resumeListeners.delete(fn); };
+ async function takeOver(robotId, subject, role) {
+ ownerSeen.set(robotId, { at: now(), subject, role });
+ if (ownerSeen.size > 10000) ownerSeen.delete(ownerSeen.keys().next().value);
+ const paused = await db.maybe(`UPDATE robot_queue SET paused_at = $2, paused_by = $3 WHERE robot_id = $1 AND state = 'active' AND paused_at IS NULL RETURNING subject`,
+ [robotId, iso(now()), role]);
+ if (paused) handedOff(robotId, paused.subject, 'owner_takeover');
+ return !!paused;
+ }
+ async function giveBack(robotId) {
+ ownerSeen.delete(robotId);
+ return await currentTurn(robotId);
+ }
/** The active turn for a robot, expiring it and promoting the oldest waiting row when it has ended. */
/** The active turn for a robot, expiring it and promoting the oldest waiting row when it has ended. */
async function currentTurn(robotId, q = db) {
async function currentTurn(robotId, q = db) {
const robot = await q.maybe('SELECT * FROM robots WHERE id = $1', [robotId]);
const robot = await q.maybe('SELECT * FROM robots WHERE id = $1', [robotId]);
if (!robot) return null;
if (!robot) return null;
let active = await q.maybe(`SELECT * FROM robot_queue WHERE robot_id = $1 AND state = 'active'`, [robotId]);
let active = await q.maybe(`SELECT * FROM robot_queue WHERE robot_id = $1 AND state = 'active'`, [robotId]);
+ if (active && active.paused_at) {
+ // Paused by an owner or operator: it waits, clock stopped, until they have been idle OWNER_IDLE_MS or gave it back.
+ const seen = ownerSeen.get(robotId);
+ if (seen && now() - seen.at < OWNER_IDLE_MS) return active;
+ const pausedMs = Math.max(0, now() - new Date(active.paused_at).getTime());
+ const resumed = await q.maybe(`UPDATE robot_queue SET paused_at = NULL, paused_by = NULL, turn_ends_at = turn_ends_at + make_interval(secs => $3::double precision / 1000)
+ WHERE robot_id = $1 AND subject = $2 AND state = 'active' AND paused_at IS NOT NULL RETURNING *`, [robotId, active.subject, pausedMs]);
+ if (resumed) {
+ ownerSeen.delete(robotId);
+ for (const fn of resumeListeners) { try { fn(robotId, { subject: resumed.subject }); } catch { /* a listener never breaks the caller */ } }
+ }
+ active = resumed || await q.maybe(`SELECT * FROM robot_queue WHERE robot_id = $1 AND state = 'active'`, [robotId]);
+ }
if (active && new Date(active.turn_ends_at).getTime() > now()) return active;
if (active && new Date(active.turn_ends_at).getTime() > now()) return active;
if (active) {
if (active) {
// Only the caller that ends the turn reports it (two at once both read it active).
// Only the caller that ends the turn reports it (two at once both read it active).
@@ -900,6 +945,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -900,6 +945,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
return {
return {
robot_id: robotId, subject: subject || null, active: !!(active && active.subject === subject),
robot_id: robotId, subject: subject || null, active: !!(active && active.subject === subject),
turn_ends_at: active ? iso(new Date(active.turn_ends_at).getTime()) : null,
turn_ends_at: active ? iso(new Date(active.turn_ends_at).getTime()) : null,
+ // An owner or operator has taken over: the turn is paused (its clock stopped) and resumes after they stop.
+ paused: active && active.paused_at ? { by: active.paused_by || 'owner', since: iso(new Date(active.paused_at).getTime()), resumes_after_idle_ms: OWNER_IDLE_MS } : null,
position: active && active.subject === subject ? 0 : position,
position: active && active.subject === subject ? 0 : position,
budget: turnBudget(robot),
budget: turnBudget(robot),
used: active && active.subject === subject ? active.commands_used : 0,
used: active && active.subject === subject ? active.commands_used : 0,
@@ -908,16 +955,23 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -908,16 +955,23 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
async function consumeTurn(robotId, subject) {
async function consumeTurn(robotId, subject) {
const active = await currentTurn(robotId);
const active = await currentTurn(robotId);
if (!active || active.subject !== subject) return { ok: false, code: 'bot.not_your_turn' };
if (!active || active.subject !== subject) return { ok: false, code: 'bot.not_your_turn' };
+ if (active.paused_at) return { ok: false, code: 'bot.owner_control' };
const robot = await getRobot(robotId);
const robot = await getRobot(robotId);
if (active.commands_used >= turnBudget(robot)) return { ok: false, code: 'bot.turn_budget' };
if (active.commands_used >= turnBudget(robot)) return { ok: false, code: 'bot.turn_budget' };
- await db.query(`UPDATE robot_queue SET commands_used = commands_used + 1 WHERE robot_id = $1 AND subject = $2`, [robotId, subject]);
- return { ok: true };
+ // Only a turn that is still active spends (the sweep may have ended it since it was read).
+ const spent = await db.maybe(`UPDATE robot_queue SET commands_used = commands_used + 1 WHERE robot_id = $1 AND subject = $2 AND state = 'active' RETURNING 1 AS ok`, [robotId, subject]);
+ return spent ? { ok: true } : { ok: false, code: 'bot.not_your_turn' };
}
}
/** Expire ended turns and promote the next waiting person; returns the robots that changed. */
/** Expire ended turns and promote the next waiting person; returns the robots that changed. */
async function sweepQueues() {
async function sweepQueues() {
const changed = new Set();
const changed = new Set();
- const ended = await db.many(`SELECT DISTINCT robot_id FROM robot_queue WHERE state = 'active' AND turn_ends_at <= $1`, [iso(now())]);
- for (const r of ended) { await currentTurn(r.robot_id); changed.add(r.robot_id); }
+ const ended = await db.many(`SELECT DISTINCT robot_id FROM robot_queue WHERE state = 'active' AND (turn_ends_at <= $1 OR paused_at IS NOT NULL)`, [iso(now())]);
+ for (const r of ended) {
+ const before = await db.maybe(`SELECT subject, paused_at FROM robot_queue WHERE robot_id = $1 AND state = 'active'`, [r.robot_id]);
+ const after = await currentTurn(r.robot_id);
+ // A turn still paused and still the same holder changed nothing a panel shows.
+ if (!(before && before.paused_at && after && after.paused_at && after.subject === before.subject)) changed.add(r.robot_id);
+ }
const idle = await db.many(`SELECT DISTINCT q.robot_id FROM robot_queue q WHERE q.state = 'waiting'
const idle = await db.many(`SELECT DISTINCT q.robot_id FROM robot_queue q WHERE q.state = 'waiting'
AND NOT EXISTS (SELECT 1 FROM robot_queue a WHERE a.robot_id = q.robot_id AND a.state = 'active')`);
AND NOT EXISTS (SELECT 1 FROM robot_queue a WHERE a.robot_id = q.robot_id AND a.state = 'active')`);
for (const r of idle) { await currentTurn(r.robot_id); changed.add(r.robot_id); }
for (const r of idle) { await currentTurn(r.robot_id); changed.add(r.robot_id); }
@@ -1107,6 +1161,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1107,6 +1161,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
* { ok:false, code, reason, robot?, role? }. `online` is the live socket state the hub knows.
* { ok:false, code, reason, robot?, role? }. `online` is the live socket state the hub knows.
*/
*/
async function prepare({ robotId, principal, kind, value = {}, online = false, requestedMs = null }) {
async function prepare({ robotId, principal, kind, value = {}, online = false, requestedMs = null }) {
+ const epoch = epochOf(robotId); // before the first await: see epochs above
const robot = await getRobot(robotId);
const robot = await getRobot(robotId);
if (!robot) return { ok: false, code: 'bot.robot_not_found', reason: 'no such robot' };
if (!robot) return { ok: false, code: 'bot.robot_not_found', reason: 'no such robot' };
const profileRow = await getProfile(db, robot.profile_id, robot.profile_version);
const profileRow = await getProfile(db, robot.profile_id, robot.profile_version);
@@ -1144,15 +1199,20 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1144,15 +1199,20 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
return { ok: false, code: 'bot.cooldown', reason: `wait ${eff.cooldownMs} ms between ${kind} commands`, robot, role };
return { ok: false, code: 'bot.cooldown', reason: `wait ${eff.cooldownMs} ms between ${kind} commands`, robot, role };
}
}
}
}
+ const PAUSED = 'the owner has taken control: your turn is paused and keeps its time';
if (role === 'queue') {
if (role === 'queue') {
// A halt needs the turn (a waiting person may not stop the driver) but spends none of its budget.
// A halt needs the turn (a waiting person may not stop the driver) but spends none of its budget.
if (halt || release || repeatedHold) {
if (halt || release || repeatedHold) {
const turn = await currentTurn(robotId);
const turn = await currentTurn(robotId);
if (!turn || turn.subject !== subject) return { ok: false, code: 'bot.not_your_turn', reason: 'it is not your turn', robot, role };
if (!turn || turn.subject !== subject) return { ok: false, code: 'bot.not_your_turn', reason: 'it is not your turn', robot, role };
+ if (turn.paused_at) return { ok: false, code: 'bot.owner_control', reason: PAUSED, robot, role };
} else {
} else {
const budget = await consumeTurn(robotId, subject);
const budget = await consumeTurn(robotId, subject);
- if (!budget.ok) return { ok: false, code: budget.code, reason: budget.code === 'bot.turn_budget' ? 'your turn budget is spent' : 'it is not your turn', robot, role };
+ if (!budget.ok) return { ok: false, code: budget.code, reason: budget.code === 'bot.turn_budget' ? 'your turn budget is spent' : budget.code === 'bot.owner_control' ? PAUSED : 'it is not your turn', robot, role };
}
}
+ } else if ((role === 'owner' || role === 'operator') && robot.access_policy === 'queue' && subject) {
+ // Owner takeover: the holder's turn pauses and the robot halts before this command goes (takeOver).
+ await takeOver(robotId, subject, role);
}
}
let built;
let built;
try { built = buildValue(kind, value, eff, profile); } catch (e) { return { ok: false, code: e.code || 'bot.invalid_input', reason: e.detail || e.message, robot, role }; }
try { built = buildValue(kind, value, eff, profile); } catch (e) { return { ok: false, code: e.code || 'bot.invalid_input', reason: e.detail || e.message, robot, role }; }
@@ -1171,7 +1231,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1171,7 +1231,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
if (activeKey && built.state === 'down') activeButtons.set(activeKey, deadlineMs);
if (activeKey && built.state === 'down') activeButtons.set(activeKey, deadlineMs);
if (activeKey && built.state === 'up') activeButtons.delete(activeKey);
if (activeKey && built.state === 'up') activeButtons.delete(activeKey);
if (activeButtons.size > 4096) activeButtons.delete(activeButtons.keys().next().value);
if (activeButtons.size > 4096) activeButtons.delete(activeButtons.keys().next().value);
- return { ok: true, robot, profile, role, kind, value: built, deadlineMs, limits: eff };
+ return { ok: true, robot, profile, role, kind, value: built, deadlineMs, limits: eff, epoch };
}
}
return {
return {
@@ -1187,7 +1247,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1187,7 +1247,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
streaming: { get: streaming, set: setStreaming },
streaming: { get: streaming, set: setStreaming },
video: { live: liveVideo },
video: { live: liveVideo },
estop: { set: setEstop, clear: clearEstop },
estop: { set: setEstop, clear: clearEstop },
- queue: { join: joinQueue, leave: leaveQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues, onHandoff },
+ queue: { join: joinQueue, leave: leaveQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues, onHandoff, onResume, epoch: epochOf, giveBack, OWNER_IDLE_MS },
audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
control: { prepare, allowedFor, effectiveLimits, deviceLimits, buildValue, DEFAULT_ALLOW, KINDS },
control: { prepare, allowedFor, effectiveLimits, deviceLimits, buildValue, DEFAULT_ALLOW, KINDS },
};
};
@@ -492,6 +492,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -492,6 +492,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
case 'command': return onOperatorCommand(conn, msg);
case 'command': return onOperatorCommand(conn, msg);
case 'estop': return onOperatorEstop(conn, true);
case 'estop': return onOperatorEstop(conn, true);
case 'estop_clear': return onOperatorEstop(conn, false);
case 'estop_clear': return onOperatorEstop(conn, false);
+ case 'give_back': return onGiveBack(conn);
default: return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);
default: return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);
}
}
}
}
@@ -552,6 +553,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -552,6 +553,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
await domain.audit.record({ robotId, deviceId: device ? device.device.id : null, subject: conn.subject, operatorKind: conn.principal.kind, role: decision.role || conn.role, kind: msg.kind, value: msg.value || {}, result: 'refused', reason: decision.code });
await domain.audit.record({ robotId, deviceId: device ? device.device.id : null, subject: conn.subject, operatorKind: conn.principal.kind, role: decision.role || conn.role, kind: msg.kind, value: msg.value || {}, result: 'refused', reason: decision.code });
return sendFrame(conn, 'command_result', { id, result: 'refused', code: decision.code, reason: decision.reason });
return sendFrame(conn, 'command_result', { id, result: 'refused', code: decision.code, reason: decision.reason });
}
}
+ // A queue turn that ended while the gate was deciding (domain epochs): its halt is already out, so this command
+ // would follow it into the next turn. Checked here, with no await before the send.
+ if (decision.role === 'queue' && domain.queue.epoch(robotId) !== decision.epoch) {
+ await domain.audit.record({ robotId, deviceId: device ? device.device.id : null, subject: conn.subject, operatorKind: conn.principal.kind, role: decision.role, kind: decision.kind, value: decision.value, result: 'refused', reason: 'bot.turn_ended' });
+ return sendFrame(conn, 'command_result', { id, result: 'refused', code: 'bot.not_your_turn', reason: 'your turn ended before this command could be sent' });
+ }
const deviceCmdId = prefixedId('cmd', now());
const deviceCmdId = prefixedId('cmd', now());
const frame = { v: 1, type: 'command', seq: nextSeq(device), ts: now(), id: deviceCmdId, ref: id, kind: decision.kind, value: decision.value, deadline_ms: decision.deadlineMs, operator: { subject: conn.subject, role: decision.role }, robot_id: robotId };
const frame = { v: 1, type: 'command', seq: nextSeq(device), ts: now(), id: deviceCmdId, ref: id, kind: decision.kind, value: decision.value, deadline_ms: decision.deadlineMs, operator: { subject: conn.subject, role: decision.role }, robot_id: robotId };
if (!send(device.ws, frame)) {
if (!send(device.ws, frame)) {
@@ -723,6 +730,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -723,6 +730,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
}
}
}
}
+ /** `give_back`: an owner or operator who took over a queue turn hands it back now (it resumes with its time). */
+ async function onGiveBack(conn) {
+ if (!conn.robotId) return sendError(conn, 'bot.not_joined', 'join a robot first');
+ const role = await domain.members.roleOf(conn.robotId, conn.subject);
+ if (!['owner', 'operator'].includes(role)) return sendError(conn, 'bot.forbidden', 'only the owner or an operator takes over and gives back');
+ await domain.queue.giveBack(conn.robotId);
+ broadcast(conn.robotId);
+ return undefined;
+ }
+
async function onOperatorEstop(conn, latched) {
async function onOperatorEstop(conn, latched) {
if (!conn.robotId) return sendError(conn, 'bot.not_joined', 'join a robot first');
if (!conn.robotId) return sendError(conn, 'bot.not_joined', 'join a robot first');
const robotId = conn.robotId;
const robotId = conn.robotId;
@@ -933,7 +950,14 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -933,7 +950,14 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
for (const t of timers) if (t.unref) t.unref();
for (const t of timers) if (t.unref) t.unref();
return {
return {
- bindDomain(d) { domain = d; d.queue.onHandoff(haltForTurn); },
+ bindDomain(d) {
+ domain = d;
+ d.queue.onHandoff(haltForTurn);
+ // A turn that ends, is left or pauses for an owner takeover, and one that resumes, repaints every panel now
+ // (after the halt has gone), not at the next sweep.
+ d.queue.onHandoff((robotId) => setImmediate(() => broadcast(robotId)));
+ if (d.queue.onResume) d.queue.onResume((robotId) => setImmediate(() => broadcast(robotId)));
+ },
bindJobs(j) { jobs = j; },
bindJobs(j) { jobs = j; },
handleUpgrade, attachSim, detachSim,
handleUpgrade, attachSim, detachSim,
/** A one-time ticket for the embedded panel's Drive (POST /panel/:id/ticket), or null. */
/** A one-time ticket for the embedded panel's Drive (POST /panel/:id/ticket), or null. */
@@ -429,13 +429,16 @@ ${(people || []).map(row).join('\n')}
@@ -429,13 +429,16 @@ ${(people || []).map(row).join('\n')}
}
}
/**
/**
- * The turn strip a signed-in non-member sees on a `queue` robot's panel: the active turn's countdown or their
- * place in line, and a plain leave form that works without JavaScript (public/panel.js unhides it and paints
- * the state). Hidden until a state frame says there is a queue view for this person.
+ * The turn strip on a `queue` robot's panel. A signed-in non-member sees the active turn's countdown or their place in
+ * line, with a plain leave form that works without JavaScript. The owner and operators see it only while their own
+ * driving has paused someone's turn (owner takeover), with "Give back now". public/panel.js unhides and paints it from
+ * the state frames; it stays hidden until one says there is something to show this person.
*/
*/
function turnStrip(robot, role) {
function turnStrip(robot, role) {
- if (robot.access_policy !== 'queue' || role !== 'queue') return '';
- return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span><form method="post" action="/robots/${esc(robot.id)}/queue/leave" data-turn-leave><button type="submit" class="quiet">Leave the queue</button></form></div>`;
+ if (robot.access_policy !== 'queue' || !['queue', 'owner', 'operator'].includes(role)) return '';
+ const leave = role === 'queue' ? `<form method="post" action="/robots/${esc(robot.id)}/queue/leave" data-turn-leave><button type="submit" class="quiet">Leave the queue</button></form>` : '';
+ const give = role === 'queue' ? '' : '<button type="button" class="quiet" data-turn-give hidden>Give back now</button>';
+ return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span>${leave}${give}</div>`;
}
}
function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null }) {
function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null }) {
@@ -292,6 +292,103 @@ const plugins = require('./helpers/plugins');
@@ -292,6 +292,103 @@ const plugins = require('./helpers/plugins');
c.close(); d.close();
c.close(); d.close();
});
});
+ await check('a command whose turn ends while the gate decides is refused, never sent after the handoff halt', async () => {
+ // The race a LOOI driver's reviewers asked about (A2): the gate read carol's turn, the turn ended (the sweep, or
+ // she left) during the gate's later awaits and the halt went out, and her command followed the halt into dave's
+ // turn. The control epoch moves with the halt; a queue command decided under the old one is refused.
+ const { robot, pairing } = await t.robot(alex, { access_policy: 'queue', limits: { turn_ms: 60000, turn_budget: 5 } });
+ const dev = await deviceFor(robot.id, pairing.code);
+ const c = await op(carol);
+ await joinResult(c, robot.id);
+ const d = await op(dave);
+ await joinResult(d, robot.id);
+ const real = t.domain.control.prepare;
+ t.domain.control.prepare = async (args) => {
+ const decision = await real(args);
+ if (args.principal && args.principal.subject === carol.subject) await t.domain.queue.leave(robot.id, carol.subject);
+ return decision;
+ };
+ try {
+ cmd(c, { id: 'r1', kind: 'drive', value: { throttle: 0.3 } });
+ const r = await result(c, 'r1');
+ assert.deepStrictEqual([r.result, r.code], ['refused', 'bot.not_your_turn'], r.reason);
+ } finally {
+ t.domain.control.prepare = real;
+ }
+ const halt = await dev.waitFor((m) => m.type === 'command' && m.kind === 'halt');
+ dev.send({ type: 'ack', id: halt.id });
+ await t.wait(50);
+ assert.ok(!dev.messages.some((m) => m.type === 'command' && m.ref === 'r1'), 'the ended turn\'s command never reached the robot');
+ cmd(d, { id: 'r2', kind: 'drive', value: { throttle: 0.3 } });
+ const r2 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'r2');
+ dev.send({ type: 'ack', id: r2.id });
+ await result(d, 'r2');
+ assert.deepStrictEqual(dev.messages.filter((m) => m.type === 'command').map((m) => (m.kind === 'halt' ? 'halt' : m.ref)), ['halt', 'r2']);
+ const audit = await t.domain.audit.list(robot.id, { limit: 5 });
+ assert.ok(audit.some((x) => x.result === 'refused' && x.reason === 'bot.turn_ended'), 'the refusal is audited');
+ c.close(); d.close();
+ });
+
+ await check('owner takeover: the owner driving pauses the turn and halts the robot first; the turn keeps its time and resumes on give-back or after the owner is idle', async () => {
+ const { robot, pairing } = await t.robot(alex, { access_policy: 'queue', limits: { turn_ms: 60000, turn_budget: 20 } });
+ const dev = await deviceFor(robot.id, pairing.code);
+ const c = await op(carol);
+ await joinResult(c, robot.id);
+ const a = await op(alex);
+ assert.strictEqual((await joinResult(a, robot.id)).role, 'owner');
+ cmd(c, { id: 'o1', kind: 'drive', value: { throttle: 0.3 } });
+ const o1 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'o1');
+ dev.send({ type: 'ack', id: o1.id });
+ await result(c, 'o1');
+ const before = await t.domain.queue.currentTurn(robot.id);
+ cmd(a, { id: 'o2', kind: 'drive', value: { throttle: 0.5 } });
+ const o2 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'o2');
+ dev.send({ type: 'ack', id: o2.id });
+ await result(a, 'o2');
+ const cmds = dev.messages.filter((m) => m.type === 'command');
+ assert.deepStrictEqual(cmds.map((m) => (m.kind === 'halt' ? 'halt' : m.ref)), ['o1', 'halt', 'o2'], 'the robot halts before the owner drives');
+ const halt = cmds.find((m) => m.kind === 'halt');
+ dev.send({ type: 'ack', id: halt.id });
+ assert.deepStrictEqual(halt.operator, { subject: null, role: 'system' });
+ await t.wait(30);
+ const row = (await t.domain.audit.list(robot.id, { limit: 6 })).find((x) => x.kind === 'halt');
+ assert.deepStrictEqual(row.value, { reason: 'owner_takeover', turn_of: carol.subject }, 'audited as an owner takeover of carol\'s turn');
+ let q = await t.domain.queue.state(robot.id, carol.subject);
+ assert.strictEqual(q.active, true);
+ assert.strictEqual(q.paused.by, 'owner');
+ cmd(c, { id: 'o3', kind: 'drive', value: { throttle: 0.3 } });
+ const r3 = await result(c, 'o3');
+ assert.deepStrictEqual([r3.result, r3.code], ['refused', 'bot.owner_control'], 'the paused holder does not drive');
+ // The owner gives it back: the turn resumes at once, its end moved on by the paused time.
+ await t.wait(40);
+ a.send({ type: 'give_back' });
+ for (let i = 0; i < 50 && (await t.domain.queue.state(robot.id, carol.subject)).paused; i++) await t.wait(20);
+ q = await t.domain.queue.state(robot.id, carol.subject);
+ assert.strictEqual(q.paused, null, 'given back');
+ assert.ok(new Date(q.turn_ends_at).getTime() > new Date(before.turn_ends_at).getTime(), 'the paused time is added to the turn');
+ cmd(c, { id: 'o4', kind: 'drive', value: { throttle: 0.3 } });
+ const o4 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'o4');
+ dev.send({ type: 'ack', id: o4.id });
+ assert.strictEqual((await result(c, 'o4')).result, 'ack', 'carol drives again');
+ // Taken over again, then the owner goes idle: the sweep resumes the turn after OWNER_IDLE_MS.
+ cmd(a, { id: 'o5', kind: 'drive', value: { throttle: 0.5 } });
+ const o5 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'o5');
+ dev.send({ type: 'ack', id: o5.id });
+ await result(a, 'o5');
+ assert.ok((await t.domain.queue.state(robot.id, carol.subject)).paused, 'paused again');
+ t.clock.offset += t.domain.queue.OWNER_IDLE_MS + 1000;
+ try {
+ await t.domain.queue.sweep();
+ assert.strictEqual((await t.domain.queue.state(robot.id, carol.subject)).paused, null, 'resumed once the owner is idle');
+ } finally {
+ t.clock.offset -= t.domain.queue.OWNER_IDLE_MS + 1000;
+ }
+ // Someone not allowed to take over cannot give back.
+ c.send({ type: 'give_back' });
+ assert.strictEqual((await c.waitFor((m) => m.type === 'error' && m.code === 'bot.forbidden')).code, 'bot.forbidden');
+ c.close(); a.close();
+ });
+
await check('a service token with bot.robot.control acts for a viewer and is held to the viewer role', async () => {
await check('a service token with bot.robot.control acts for a viewer and is held to the viewer role', async () => {
const { robot, pairing } = await t.robot(alex);
const { robot, pairing } = await t.robot(alex);
const viewer = t.network.newUser('viewer');
const viewer = t.network.newUser('viewer');
@@ -148,13 +148,14 @@ function identityStub() {
@@ -148,13 +148,14 @@ function identityStub() {
assert.ok(back.includes('<b>Carol</b><small>@carol · Viewer</small>'));
assert.ok(back.includes('<b>Carol</b><small>@carol · Viewer</small>'));
});
});
- await check('a queue robot: a signed-in non-member gets the turn strip; the owner does not', async () => {
+ await check('a queue robot: a signed-in non-member gets the turn strip; the owner gets only its give-back form', async () => {
const { robot: q } = await t.robot(alex, { name: 'Queue', access_policy: 'queue' });
const { robot: q } = await t.robot(alex, { name: 'Queue', access_policy: 'queue' });
const html = await (await get(`/panel/${q.id}`, bob)).text();
const html = await (await get(`/panel/${q.id}`, bob)).text();
assert.ok(html.includes('<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span>'));
assert.ok(html.includes('<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span>'));
assert.ok(html.includes(`<form method="post" action="/robots/${q.id}/queue/leave" data-turn-leave>`));
assert.ok(html.includes(`<form method="post" action="/robots/${q.id}/queue/leave" data-turn-leave>`));
const owner = await (await get(`/panel/${q.id}`, alex)).text();
const owner = await (await get(`/panel/${q.id}`, alex)).text();
- assert.ok(!owner.includes('data-turn'), 'the owner sees no leave strip');
+ assert.ok(!owner.includes('data-turn-leave'), 'the owner sees no leave strip');
+ assert.ok(owner.includes('<button type="button" class="quiet" data-turn-give hidden>Give back now</button>'), 'the owner\'s strip, hidden until a takeover pauses a turn, only gives back');
assert.ok(owner.includes('Anyone signed in can also take a turn'), 'the queue note is on the owner card');
assert.ok(owner.includes('Anyone signed in can also take a turn'), 'the queue note is on the owner card');
// Bob joins on /control (the queue's own door) and becomes the active turn; Carol waits.
// Bob joins on /control (the queue's own door) and becomes the active turn; Carol waits.