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)

OP OpenVibers committed 20 days ago · 8282ab4245 · 8 files, +275 −23

Changes

ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex 9f0c551..bc1f7f2 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -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
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
--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 no
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
@@@ -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`, `rol
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`,
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
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
ddiff --git a/migrations/0021_owner_takeover.sql b/migrations/0021_owner_takeover.sql
nnew file mode 100644
iindex 0000000..238d457
---- /dev/null
++++ b/migrations/0021_owner_takeover.sql
@@@ -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;
ddiff --git a/public/panel.js b/public/panel.js
iindex 0b2062c..5d53d39 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -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');
}
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 8a68811..e48e2fd 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -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 above
const 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 },
};
ddiff --git a/server/realtime.js b/server/realtime.js
iindex d1f669c..6f03197 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -492,6 +492,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
case '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 = () => 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 });
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 = () => Dat
for (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. */
ddiff --git a/server/web/render.js b/server/web/render.js
iindex c795e3f..c05b1ac 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -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 }) {
ddiff --git a/test/control.test.js b/test/control.test.js
iindex a7244b2..daa604f 100644
---- a/test/control.test.js
++++ b/test/control.test.js
@@@ -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');
ddiff --git a/test/people.test.js b/test/people.test.js
iindex 97aac2e..b0d3d3e 100644
---- a/test/people.test.js
++++ b/test/people.test.js
@@@ -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.