Let workers ask each other through the coordinator in chatrooms

A worker asks for another bot's help with ask_crew. The coordinator
reads the request, asks the right bot (in a workspace, only its
members), and sends the answer back into the asking bot's
conversation, waking it. Each exchange is kept as a read-only
chatroom: listed in the main rail after the workspaces, or in the
workspace's rail for requests made there, with live status rows for
each bot while it's open and a Stop button.

Room sessions are hidden from every conversation list, other bots'
words reach the model fenced as data, and turns, nesting and open
rooms are capped. Closed rooms are pruned after a retention period
and past a cap per place (Settings > General).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-27 18:57:34 +01:00
co-authored by Claude Opus 5.5
parent 2d3c15e48b
commit 7b762fd384
139 changed files with 4246 additions and 64 deletions
+3 -1
View File
@@ -3,6 +3,7 @@ import { AppData } from "@/src/data/app-data";
import { logError } from "@/src/functions/backend/logger";
import grabConnectedUserIds from "@/src/websocket/(functions)/grab-connected-user-ids";
import relayChangedBots from "./relay-changed-bots";
import relayChangedChatrooms from "./relay-changed-chatrooms";
import relayChangedSessions from "./relay-changed-sessions";
import relayChangedWorkspaces from "./relay-changed-workspaces";
import relayCreatedBots from "./relay-created-bots";
@@ -14,7 +15,7 @@ import type { LiveRelayCursor } from "./types";
/**
* Keeps this process's sockets current with work done anywhere: every
* `AppData.LiveRelayPollMs` it reads new bot messages, new job steps,
* new workspaces and changed bots from the database and pushes them to
* new workspaces, changed chatrooms and changed bots from the database and pushes them to
* the sockets here of the users they're for. The process that did the work pushes at once
* (`pushBotNotice`); this catches the rest, e.g. a job run by another
* server on the same database. Clients ignore what they already have.
@@ -76,6 +77,7 @@ export default async function liveRelay() {
await relayCreatedWorkspaces({ since: bots_since, users });
await relayChangedBots({ since: bots_since, users });
await relayChangedWorkspaces({ since: bots_since, users });
await relayChangedChatrooms({ since: bots_since, users });
cursor.bots_at = now;
await relayChangedSessions({
+35
View File
@@ -0,0 +1,35 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CHATROOMS } from "@/db/types/db";
import chatroomSummaries from "@/src/functions/backend/db/chatrooms/chatroom-summaries";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
/**
* Unix ms: rooms opened or changed since then are pushed.
*/
since: number;
users: Set<string>;
};
/**
* Pushes `server:chatroom-updated` for every room opened or changed since
* `since` (a new message, a turn, a final status) to its connected user.
* Pushes are idempotent, so overlapping windows only repeat them.
*/
export default async function relayChangedChatrooms({ since, users }: Params) {
if (!users.size) return;
const rooms =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_CHATROOMS>({
sql: `SELECT * FROM chatrooms WHERE updated_at > ? OR last_activity_at > ? LIMIT 200`,
values: [since, since],
})
).payload || [];
const mine = rooms.filter((room) => room.user_id && users.has(String(room.user_id)));
for (const summary of await chatroomSummaries({ chatrooms: mine })) {
sendToUser({ user_id: summary.user_id!, data: { event: "server:chatroom-updated", data: summary } });
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ export default async function relayChangedSessions({ since, users }: Params) {
const sessions =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT id, user_id, bot_id, workspace_id FROM bot_sessions WHERE updated_at > ?`,
sql: `SELECT id, user_id, bot_id, workspace_id FROM bot_sessions WHERE updated_at > ? AND chatroom_id IS NULL`,
values: [since],
})
).payload || [];
+16 -4
View File
@@ -12,13 +12,14 @@ type Params = {
/**
* Pushes `server:bot-message` for every message newer than `after_id` to
* the sockets on this process of the user whose conversation it's in,
* with the message a reply quotes. Returns the newest id read.
* with the message a reply quotes (`server:chatroom-message` for one in a
* chatroom). Returns the newest id read.
*/
export default async function relayNewBotMessages({ after_id, users }: Params) {
const read =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES & { user_id: number }>({
sql: `SELECT m.*, s.user_id FROM bot_messages m JOIN bot_sessions s ON s.id = m.session_id
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES & { user_id: number; chatroom_id: number | null }>({
sql: `SELECT m.*, s.user_id, s.chatroom_id FROM bot_messages m JOIN bot_sessions s ON s.id = m.session_id
WHERE m.id > ? ORDER BY m.id ASC LIMIT 500`,
values: [after_id],
})
@@ -38,9 +39,20 @@ export default async function relayNewBotMessages({ after_id, users }: Params) {
.map((row) => Number(row.id)),
});
for (const { user_id, ...message } of rows) {
for (const { user_id, chatroom_id, ...message } of rows) {
const reply_to = replies.get(Number(message.id));
/**
* A bot's part of a chatroom: the room shows it, no conversation.
*/
if (chatroom_id) {
sendToUser({
user_id,
data: { event: "server:chatroom-message", data: { chatroom_id: Number(chatroom_id), message } },
});
continue;
}
sendToUser({
user_id,
data: {