Keep bot jobs and harness calls from hanging

This commit is contained in:
2026-10-20 12:59:24 +01:00
parent 3e13052a25
commit 3b355d68bc
16 changed files with 420 additions and 29 deletions
+3 -3
View File
@@ -100,8 +100,8 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
- **Avatars:** `BotStone` (`src/components/general/bot-stone.tsx`) renders the stone inline from `src/data/stone-geometry.ts`, hue as a fill token, face (`idle|working|done|asleep|needs-you`) drawn as eyes. The coordinator is hue-less (ink body, paper eyes; inverts in dark). Never put a stone in a circle/badge or add outline/shadow. Wordmark is vector (`Wordmark`, `src/data/wordmark-paths.ts`), never live text. - **Avatars:** `BotStone` (`src/components/general/bot-stone.tsx`) renders the stone inline from `src/data/stone-geometry.ts`, hue as a fill token, face (`idle|working|done|asleep|needs-you`) drawn as eyes. The coordinator is hue-less (ink body, paper eyes; inverts in dark). Never put a stone in a circle/badge or add outline/shadow. Wordmark is vector (`Wordmark`, `src/data/wordmark-paths.ts`), never live text.
- **Copy helpers:** `src/utils/crew-status.ts` (`3 working · 1 needs you`, "Three bots are working. One needs you."), `src/utils/relative-time.ts` (relative timestamps only). - **Copy helpers:** `src/utils/crew-status.ts` (`3 working · 1 needs you`, "Three bots are working. One needs you."), `src/utils/relative-time.ts` (relative timestamps only).
- **Prompt and memory (self-learning):** a bot has `system_prompt` (core instructions) and a separate core `memory` (markdown, general knowledge about its job, the same for every user it works for, never about one user). What it remembers about a user lives in `bot_user_memories` (one row per bot and user, seen only by that user's runs; `grab-bot-user-memory`, `update-bot-user-memory`), like workspace memory stays in its workspace. The bot rewrites them as it adapts (`update-bot-system-prompt`, `update-bot-memory` in `src/functions/backend/db/bots/`; whole-text replace, capped at `AppData.PromptMaxLength`). `grab-bot-prompt` builds the full prompt for the run's user via `composeBotPrompt` (`src/utils/compose-bot-prompt.ts`): core instructions, then (in a workspace only) `# Workspace: <name>` with its description and `system_prompt`, the bot's `# Your instructions in <name>` (`workspace_bots.system_prompt`), `# Memory`, `# Memory about the user`, then `# Memory in <name>` (`workspace_bots.memory`). `update_memory` saves to `user` by default (`workspace` in a workspace) and to `core` only for what holds for everyone. In a workspace the bot learns into the workspace's instructions and memory by default (`scope: workspace` on `update_instructions`/`update_memory`, `WorkspaceLearningNote`) and into its core ones only when told to, or after suggesting it. `src/scripts/move-bot-memory-to-owners.ts` (run once per install) moved core memory written before this into the owner's. - **Prompt and memory (self-learning):** a bot has `system_prompt` (core instructions) and a separate core `memory` (markdown, general knowledge about its job, the same for every user it works for, never about one user). What it remembers about a user lives in `bot_user_memories` (one row per bot and user, seen only by that user's runs; `grab-bot-user-memory`, `update-bot-user-memory`), like workspace memory stays in its workspace. The bot rewrites them as it adapts (`update-bot-system-prompt`, `update-bot-memory` in `src/functions/backend/db/bots/`; whole-text replace, capped at `AppData.PromptMaxLength`). `grab-bot-prompt` builds the full prompt for the run's user via `composeBotPrompt` (`src/utils/compose-bot-prompt.ts`): core instructions, then (in a workspace only) `# Workspace: <name>` with its description and `system_prompt`, the bot's `# Your instructions in <name>` (`workspace_bots.system_prompt`), `# Memory`, `# Memory about the user`, then `# Memory in <name>` (`workspace_bots.memory`). `update_memory` saves to `user` by default (`workspace` in a workspace) and to `core` only for what holds for everyone. In a workspace the bot learns into the workspace's instructions and memory by default (`scope: workspace` on `update_instructions`/`update_memory`, `WorkspaceLearningNote`) and into its core ones only when told to, or after suggesting it. `src/scripts/move-bot-memory-to-owners.ts` (run once per install) moved core memory written before this into the owner's.
- **Queue:** every bot job goes through `bots_queue`, with `kind` (`BotJobKinds`): `reply` (answers the user in chat, no task row) or `task` (long-running work, typically on a computer). Long-running work goes through `bots_queue`, a bun-sqlite `isQueue` table worked with the native `BunSQLite.queue` API (`status` pending|processing|completed|failed, `attempts`, `available_at`, `locked_at`/`locked_by`, `last_error`; options in `AppData.BotQueue*`). Not every message needs a job: the bot replies right away that it's on it and `enqueueBotJob` queues the brief (`instructions`, `params`, optional `message_id`/`computer_id`, the computer checked against the bot's access; a `bot_tasks` row is created unless `task_id` is given). DB helpers in `src/functions/backend/db/bots-queue/`: `claimBotJobs`, `updateBotJob` (renews the lock, saves `progress`/`checkpoint`; false once lost or cancelled), `completeBotJob` (result message, task done, face done/needs-you for the job's user), `failBotJob` (backoff retry, or `fatal`; out of attempts posts a needs-you message via `notifyBotJobFailed`), `cancelBotJob` (failed with `BotJobCancelledError`, `src/dict/bot-queue-dict.ts`), `releaseWorkerBotJobs`. Every model turn of a job is a step in `bots_queue_updates` (`kind` tool|note, one-line `text`, JSON `metadata`; tool names, never arguments; `describe-job-step.ts`), written through the job context's `report` (the text loop's `onStep`), which also sets `progress` and pushes `server:bot-job-update` (`BotJobUpdateData`). Steps are deleted with their job when it's pruned. The active task's latest `AppData.BotJobUpdatesShown` steps ride on `BotTaskWithSteps.updates` (`grabBotJobUpdates`); the conversation (`bot_task` page prop, `use-task-progress`) shows the latest one under `BotThinkingRow`'s "<name> is working…" while a task runs. Step text is the first line the model wrote, else plain labels for its tools (`JobStepToolLabels` in `src/dict/bot-queue-dict.ts`; other tools in words, an MCP tool as "Using Exa search (web search exa)"). - **Queue:** every bot job goes through `bots_queue`, with `kind` (`BotJobKinds`): `reply` (answers the user in chat, no task row) or `task` (long-running work, typically on a computer). Long-running work goes through `bots_queue`, a bun-sqlite `isQueue` table worked with the native `BunSQLite.queue` API (`status` pending|processing|completed|failed, `attempts`, `available_at`, `locked_at`/`locked_by`, `last_error`; options in `AppData.BotQueue*`). Not every message needs a job: the bot replies right away that it's on it and `enqueueBotJob` queues the brief (`instructions`, `params`, optional `message_id`/`computer_id`, the computer checked against the bot's access; a `bot_tasks` row is created unless `task_id` is given). DB helpers in `src/functions/backend/db/bots-queue/`: `claimBotJobs`, `updateBotJob` (renews the lock, saves `progress`/`checkpoint`; false once lost or cancelled), `completeBotJob` (result message, task done, face done/needs-you for the job's user), `failBotJob` (backoff retry, or `fatal`; out of attempts posts a needs-you message via `notifyBotJobFailed`), `cancelBotJob` (failed with `BotJobCancelledError`, `src/dict/bot-queue-dict.ts`), `releaseWorkerBotJobs`. Every model turn of a job is a step in `bots_queue_updates` (`kind` tool|note, one-line `text`, JSON `metadata`; tool names, never arguments; `describe-job-step.ts`), written through the job context's `report` (the text loop's `onStep`), which also sets `progress` and pushes `server:bot-job-update` (`BotJobUpdateData`). Steps are deleted with their job when it's pruned. One attempt has a deadline (`AppData.BotJobAttemptTimeoutMs`): the heartbeat keeps the lock alive for as long as the run takes, so a run past the deadline is aborted, reported as a failed try and retried, and a model or tool that never answers can't hold its slot, conversation or computer forever. The active task's latest `AppData.BotJobUpdatesShown` steps ride on `BotTaskWithSteps.updates` (`grabBotJobUpdates`); the conversation (`bot_task` page prop, `use-task-progress`) shows the latest one under `BotThinkingRow`'s "<name> is working…" while a task runs. Step text is the first line the model wrote, else plain labels for its tools (`JobStepToolLabels` in `src/dict/bot-queue-dict.ts`; other tools in words, an MCP tool as "Using Exa search (web search exa)").
- **Queue handler:** `src/bots-queue/index.ts`, started from `server.ts` after cron and gated the same way. It releases its own cut-off jobs on startup (stable worker `AppData.BotQueueWorker`), then polls every `BotQueuePollMs`: reports jobs bun-sqlite failed on a lock timeout (`report-lost-bot-jobs`), claims per kind (`claimBotJobs` mirrors bun-sqlite's atomic claim plus `kind`, since `queue.claim` has no filter) up to `BotQueueReplyConcurrency` replies and `BotQueueConcurrency` tasks on separate slots, and runs each with `processBotJob` (heartbeat at a quarter of the lock timeout, abort on cancel/loss, one task per computer and one reply per session via `createKeyLock`, so a bot's other sessions answer side by side; a task waiting for its busy computer shows face `asleep` with `BotComputerBusyStatusLine`; face `working` once it starts, outcome pushed live; a job finishing while others for that user still run puts back the running one's face, `grabRunningBotJobFace`). The actual work is `run-bot-job.ts` (`BotJobRunner`, context in `types.ts`), which dispatches to `run-bot-reply.ts` and `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix. When every model is resting (`HarnessError.resting`, nothing was tried) the job is put back with `deferBotJob` until the first comes back, its attempt refunded and the bot's face `asleep`; other retryable harness errors retry no sooner than their `retry_at`. The `prune-bots-queue` cron deletes finished jobs after `BotQueueRetentionDays`. - **Queue handler:** `src/bots-queue/index.ts`, started from `server.ts` after cron and gated the same way. It releases its own cut-off jobs on startup (stable worker `AppData.BotQueueWorker`; polling starts even if that release fails, and a production process without `MAIN_SERVER` says so in the log instead of going quiet), then polls every `BotQueuePollMs`: reports jobs bun-sqlite failed on a lock timeout (`report-lost-bot-jobs`), claims per kind (`claimBotJobs` mirrors bun-sqlite's atomic claim plus `kind`, since `queue.claim` has no filter) up to `BotQueueReplyConcurrency` replies and `BotQueueConcurrency` tasks on separate slots, and runs each with `processBotJob` (heartbeat at a quarter of the lock timeout, abort on cancel/loss, one task per computer and one reply per session via `createKeyLock`, so a bot's other sessions answer side by side; a task waiting for its busy computer shows face `asleep` with `BotComputerBusyStatusLine`; face `working` once it starts, outcome pushed live; a job finishing while others for that user still run puts back the running one's face, `grabRunningBotJobFace`). The actual work is `run-bot-job.ts` (`BotJobRunner`, context in `types.ts`), which dispatches to `run-bot-reply.ts` and `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix. When every model is resting (`HarnessError.resting`, nothing was tried) the job is put back with `deferBotJob` until the first comes back, its attempt refunded and the bot's face `asleep`; other retryable harness errors retry no sooner than their `retry_at`. The `prune-bots-queue` cron deletes finished jobs after `BotQueueRetentionDays`.
- **Live updates:** server push only. `sendToUser` (`src/websocket/(functions)/send-to-user.ts`) sends to one user's sockets; `pushBotNotice` sends `server:bot-message` (the message row, user messages too, for other tabs) and `server:bot-updated` (`BotUpdatedData`: face, status line, active task). The bots layout opens one socket (twui `useWebSocket`); components listen with `useServerEvent` (`src/hooks/`), and `useLiveBots` keeps the rail and header current. The conversation dedupes messages by id. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates`, changed faces and changed bots from the database and pushes them to its own sockets, so work done by another process still shows live; clients drop what they already have. `server:bot-updated` carries the bot's `computer_id` when known, which keeps the header's computer in step across tabs, and its `workspace_ids` after a membership change, so a workspace's crew drops a bot that left it. A bot that joins the crew or a workspace is pushed as `server:bot-created` (`BotCreatedData`: its rail row, the workspaces it's in, and whether it belongs in that user's own chat), from the tool that created it and from the live relay for other processes, so the rail lists it without a reload. A workspace a bot created is pushed the same way as `server:workspace-created` (`WorkspaceCreatedData`: its rail row), from the tool and from the live relay. A deleted bot is pushed as `server:bot-removed` (`BotRemovedData`) to the audience read before the delete, since its assignments go with it, so its rail row goes at once. A deleted workspace is pushed the same way as `server:workspace-removed` (`WorkspaceRemovedData`), read before the delete too. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (`src/utils/message-preview.ts`). - **Live updates:** server push only. `sendToUser` (`src/websocket/(functions)/send-to-user.ts`) sends to one user's sockets; `pushBotNotice` sends `server:bot-message` (the message row, user messages too, for other tabs) and `server:bot-updated` (`BotUpdatedData`: face, status line, active task). The bots layout opens one socket (twui `useWebSocket`); components listen with `useServerEvent` (`src/hooks/`), and `useLiveBots` keeps the rail and header current. The conversation dedupes messages by id. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates`, changed faces and changed bots from the database and pushes them to its own sockets, so work done by another process still shows live; clients drop what they already have. `server:bot-updated` carries the bot's `computer_id` when known, which keeps the header's computer in step across tabs, and its `workspace_ids` after a membership change, so a workspace's crew drops a bot that left it. A bot that joins the crew or a workspace is pushed as `server:bot-created` (`BotCreatedData`: its rail row, the workspaces it's in, and whether it belongs in that user's own chat), from the tool that created it and from the live relay for other processes, so the rail lists it without a reload. A workspace a bot created is pushed the same way as `server:workspace-created` (`WorkspaceCreatedData`: its rail row), from the tool and from the live relay. A deleted bot is pushed as `server:bot-removed` (`BotRemovedData`) to the audience read before the delete, since its assignments go with it, so its rail row goes at once. A deleted workspace is pushed the same way as `server:workspace-removed` (`WorkspaceRemovedData`), read before the delete too. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (`src/utils/message-preview.ts`).
- **Workspaces:** `workspaces` (`name`, `description`, `system_prompt` shared by every bot in it, `computer_id` its default computer (not a foreign key), `archived`), with bots assigned through `workspace_bots` (plus that bot's `system_prompt` and `memory` there; the coordinator is an implicit member) and computers through `workspace_computers`. The first computer linked becomes the default; the user (workspace settings, chat header), any bot there (`set_workspace_computer`) or the coordinator can switch it. A workspace with no computer has none: tasks needing one fail. Workspace models (`workspace_harness_settings`) win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `delete-workspace`, `add/remove-bot-*`, `update-workspace-bot`, `add/remove-computer-*`, `set-workspace-computer`, `grab-user-workspaces`, `grab-workspace-bots`, `grab-workspace-computers`, `grab-workspace-activity`), all access-checked against the session user, the structural ones for super admins and admins only; API in `api/admin/workspaces/`. The coordinator sets a member's instructions there with `set_bot_workspace_instructions`, creates a workspace with `create_workspace`, and changes any workspace's name, description or context with `update_workspace` (chat replies, and its own tasks so findings it was asked to record land in the workspace; a chat-created one is pushed as `server:workspace-created`). - **Workspaces:** `workspaces` (`name`, `description`, `system_prompt` shared by every bot in it, `computer_id` its default computer (not a foreign key), `archived`), with bots assigned through `workspace_bots` (plus that bot's `system_prompt` and `memory` there; the coordinator is an implicit member) and computers through `workspace_computers`. The first computer linked becomes the default; the user (workspace settings, chat header), any bot there (`set_workspace_computer`) or the coordinator can switch it. A workspace with no computer has none: tasks needing one fail. Workspace models (`workspace_harness_settings`) win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `delete-workspace`, `add/remove-bot-*`, `update-workspace-bot`, `add/remove-computer-*`, `set-workspace-computer`, `grab-user-workspaces`, `grab-workspace-bots`, `grab-workspace-computers`, `grab-workspace-activity`), all access-checked against the session user, the structural ones for super admins and admins only; API in `api/admin/workspaces/`. The coordinator sets a member's instructions there with `set_bot_workspace_instructions`, creates a workspace with `create_workspace`, and changes any workspace's name, description or context with `update_workspace` (chat replies, and its own tasks so findings it was asked to record land in the workspace; a chat-created one is pushed as `server:workspace-created`).
- **Computers:** a computer is an AIO sandbox in its own docker container on the `AppData.DockerNetworkName` network. `containers` records every docker container the app knows about except the main app container (`name` doubles as the hostname, plus `docker_id`, `image`, `network`, `port`, `status`, `metadata`). `computers` (the install's, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). The app creates and manages every computer's container over the docker socket (`src/functions/backend/docker/`; `DOCKER_SOCKET` or `AppData.DockerSocketPath`): `createComputer` records it and `provisionComputer` (in the background, status `creating`, then the container's or `failed` with `metadata.error`, pushed as `server:computer-updated`) pulls `AppData.ComputerImage`, binds the home dir and starts `<ComputerContainerPrefix>-<id>` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/<id>` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. The install has one main computer, created at signup (`bun run seed`) and as a fallback by the super admin's `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`): the workspace's computers in a workspace, its default first; in its own chat, its direct grants and those of the workspaces it's attached to (the coordinator's are every computer), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`. - **Computers:** a computer is an AIO sandbox in its own docker container on the `AppData.DockerNetworkName` network. `containers` records every docker container the app knows about except the main app container (`name` doubles as the hostname, plus `docker_id`, `image`, `network`, `port`, `status`, `metadata`). `computers` (the install's, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). The app creates and manages every computer's container over the docker socket (`src/functions/backend/docker/`; `DOCKER_SOCKET` or `AppData.DockerSocketPath`): `createComputer` records it and `provisionComputer` (in the background, status `creating`, then the container's or `failed` with `metadata.error`, pushed as `server:computer-updated`) pulls `AppData.ComputerImage`, binds the home dir and starts `<ComputerContainerPrefix>-<id>` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/<id>` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. The install has one main computer, created at signup (`bun run seed`) and as a fallback by the super admin's `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`): the workspace's computers in a workspace, its default first; in its own chat, its direct grants and those of the workspaces it's attached to (the coordinator's are every computer), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`.
@@ -149,7 +149,7 @@ Envs are set in Settings, under Environment (super admin only), not in `.env`. T
- **Automatic picks** (`rank-auto-models`): only for the `automatic` harnesses in `Harnesses` (text, vision, image); transcription and speech have none and are not available until a model is picked (the model select reads "Not available", no fallback checkbox). Free models that can do the job (on the setting's provider when it has one; a provider without free models needs a picked model) (`model-serves-harness`: text needs tools), by vendor `AutoModelVendorPreference` (DeepSeek, Qwen, NVIDIA, Liquid), then other vendors that look competent (`AppData.HarnessAutoMin*`), then routers; within a vendor by intelligence index, then full-size before small, newest first. - **Automatic picks** (`rank-auto-models`): only for the `automatic` harnesses in `Harnesses` (text, vision, image); transcription and speech have none and are not available until a model is picked (the model select reads "Not available", no fallback checkbox). Free models that can do the job (on the setting's provider when it has one; a provider without free models needs a picked model) (`model-serves-harness`: text needs tools), by vendor `AutoModelVendorPreference` (DeepSeek, Qwen, NVIDIA, Liquid), then other vendors that look competent (`AppData.HarnessAutoMin*`), then routers; within a vendor by intelligence index, then full-size before small, newest first.
- **Calling:** `resolveHarnessCandidates` (pinned, then automatic) -> `withCandidates` tries them in order with process-wide cooldowns (`resolve/harness-cooldowns.ts`): rate limits and outages rest the model (until `Retry-After` when given), 401/402 rest the whole provider, 403/404 rest the model for an hour. Errors are `HarnessError` (`code` provider|unavailable|aborted, `retryable`), classified in `to-harness-error.ts`. Entry points: `text/generate-text.ts` (one turn), `text/run-text-loop.ts` (tool loop, sticks to one model, wraps up after `HarnessMaxToolSteps`, trims the oldest tool output before each turn to fit the model's context, `fit-transcript-to-context.ts`), `media/read-image.ts`, `media/read-media.ts` (documents locally via `fileToText`, images and audio through their harnesses), `audio/transcribe-audio.ts`, `audio/speak-text.ts` (returns the provider's audio as a stream, falling back to the next model only until one answers), `image/generate-image.ts`. A text model that can't take an image or audio part gets it read by the vision/transcription harness first (`prepare-messages-for-model.ts`). - **Calling:** `resolveHarnessCandidates` (pinned, then automatic) -> `withCandidates` tries them in order with process-wide cooldowns (`resolve/harness-cooldowns.ts`): rate limits and outages rest the model (until `Retry-After` when given), 401/402 rest the whole provider, 403/404 rest the model for an hour. Errors are `HarnessError` (`code` provider|unavailable|aborted, `retryable`), classified in `to-harness-error.ts`. Entry points: `text/generate-text.ts` (one turn), `text/run-text-loop.ts` (tool loop, sticks to one model, wraps up after `HarnessMaxToolSteps`, trims the oldest tool output before each turn to fit the model's context, `fit-transcript-to-context.ts`), `media/read-image.ts`, `media/read-media.ts` (documents locally via `fileToText`, images and audio through their harnesses), `audio/transcribe-audio.ts`, `audio/speak-text.ts` (returns the provider's audio as a stream, falling back to the next model only until one answers), `image/generate-image.ts`. A text model that can't take an image or audio part gets it read by the vision/transcription harness first (`prepare-messages-for-model.ts`).
- **Per-bot models:** in a workspace, its `workspace_harness_settings` row wins (`source: "workspace"`). Otherwise a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`). - **Per-bot models:** in a workspace, its `workspace_harness_settings` row wins (`source: "workspace"`). Otherwise a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`).
- **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`. Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `create_workspace` and `update_workspace` (a new workspace with its shared context, or changing one; both also in its tasks, so a task that gathers material can write it into the workspace), `create_bot` (add a worker with its name, role and instructions, and join it to a workspace straight away) and, in its own chat, `add_bot_to_workspace` (put an existing worker in one of the user's workspaces) and `remove_bot_from_workspace` (take it out, keeping its conversation there), `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so), `update_bot` (change a worker's name, role or core instructions, `src/functions/backend/db/bots/update-bot.ts`), `find_bot` and `find_workspace` (look the crew, or the live workspaces, up by id, name, role/description or instructions/context), `delete_bot` (a worker with its conversations, tasks, schedules and workspace places; the coordinator can't go) and `delete_workspace` (its conversations, tasks, schedules and links; its bots and computers stay). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`. - **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`, and caps every tool call at `AppData.HarnessToolTimeoutMs` (aborted, its result handed back as an error the model can work around). Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `create_workspace` and `update_workspace` (a new workspace with its shared context, or changing one; both also in its tasks, so a task that gathers material can write it into the workspace), `create_bot` (add a worker with its name, role and instructions, and join it to a workspace straight away) and, in its own chat, `add_bot_to_workspace` (put an existing worker in one of the user's workspaces) and `remove_bot_from_workspace` (take it out, keeping its conversation there), `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so), `update_bot` (change a worker's name, role or core instructions, `src/functions/backend/db/bots/update-bot.ts`), `find_bot` and `find_workspace` (look the crew, or the live workspaces, up by id, name, role/description or instructions/context), `delete_bot` (a worker with its conversations, tasks, schedules and workspace places; the coordinator can't go) and `delete_workspace` (its conversations, tasks, schedules and links; its bots and computers stay). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`.
- **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs). - **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs).
- **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). A bot gets the ones given to it (`bot_mcp_servers`, `bot_custom_tools`) and, in a workspace, that workspace's (`workspace_mcp_servers`, `workspace_custom_tools`); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. `all_bots` ("every bot by default") only decides what bots are given: turning it on gives it to every bot (`attachToAllBots`, the coordinator included) and every new bot gets it (`attachDefaultExtensions`, from `createBot` and `ensureCoordinatorBot`); turning it off takes it from no one, and a bot that drops one keeps it dropped (`src/scripts/attach-every-bot-extensions.ts`, safe to rerun, moved installs from the old "reaches every bot" meaning). Super admins define them. Super admins and admins (the super admin alone for the coordinator, `assertBotExtensionManager`) give and take them per bot on the bot's settings page and under its chat's composer (`composer-tools.tsx`: chips for everything it can use there, a workspace's not removable, and an Add modal; before them `composer-computer-button.tsx` opens the bot's current computer in the viewer), and per workspace on the workspace's settings page; the settings pages share `settings/(partials)/extensions-group.tsx`, all through `useExtensions` (`src/hooks/`), `grabBotExtensionsOverview` / `grabWorkspaceExtensionsOverview` (`bot_extensions` / `workspace_extensions` page props, both on `grabExtensionsOverview`) and `api/admin/bots/` and `api/admin/workspaces/` `add-extension`, `remove-extension` (`setBotExtension`, `setWorkspaceExtension`); the MCPs and Tools pages' Access modal sets bots and workspaces in bulk (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (workspace beats general, app settings never, no need to be set on computers); secret values are redacted from every result. - **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). A bot gets the ones given to it (`bot_mcp_servers`, `bot_custom_tools`) and, in a workspace, that workspace's (`workspace_mcp_servers`, `workspace_custom_tools`); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. `all_bots` ("every bot by default") only decides what bots are given: turning it on gives it to every bot (`attachToAllBots`, the coordinator included) and every new bot gets it (`attachDefaultExtensions`, from `createBot` and `ensureCoordinatorBot`); turning it off takes it from no one, and a bot that drops one keeps it dropped (`src/scripts/attach-every-bot-extensions.ts`, safe to rerun, moved installs from the old "reaches every bot" meaning). Super admins define them. Super admins and admins (the super admin alone for the coordinator, `assertBotExtensionManager`) give and take them per bot on the bot's settings page and under its chat's composer (`composer-tools.tsx`: chips for everything it can use there, a workspace's not removable, and an Add modal; before them `composer-computer-button.tsx` opens the bot's current computer in the viewer), and per workspace on the workspace's settings page; the settings pages share `settings/(partials)/extensions-group.tsx`, all through `useExtensions` (`src/hooks/`), `grabBotExtensionsOverview` / `grabWorkspaceExtensionsOverview` (`bot_extensions` / `workspace_extensions` page props, both on `grabExtensionsOverview`) and `api/admin/bots/` and `api/admin/workspaces/` `add-extension`, `remove-extension` (`setBotExtension`, `setWorkspaceExtension`); the MCPs and Tools pages' Access modal sets bots and workspaces in bulk (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (workspace beats general, app settings never, no need to be set on computers); secret values are redacted from every result.
- **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts): a saved one from its row (Try), or the form as it is, saved or not (`draft`, nothing recorded), from the Test panel in its modal (`custom-tool-test-panel.tsx`). Every run (a bot's call or a try; not bad arguments or a cancel) is saved as the tool's `status`, `last_error` and `last_checked_at` (`recordCustomToolRun`); editing resets it to `untested`. - **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts): a saved one from its row (Try), or the form as it is, saved or not (`draft`, nothing recorded), from the Test panel in its modal (`custom-tool-test-panel.tsx`). Every run (a bot's call or a try; not bad arguments or a cancel) is saved as the tool's `status`, `last_error` and `last_checked_at` (`recordCustomToolRun`); editing resets it to `untested`.
+22 -3
View File
@@ -22,15 +22,34 @@ let timer: ReturnType<typeof setTimeout> | undefined;
*/ */
export default async function botsQueue() { export default async function botsQueue() {
if (process.env.NODE_ENV == "production" && !process.env.MAIN_SERVER) { if (process.env.NODE_ENV == "production" && !process.env.MAIN_SERVER) {
/**
* Said out loud: a production-mode process without `MAIN_SERVER`
* answers requests but never replies, because nothing runs this
* queue. `bun run start` sets it.
*/
bunext.bunextLog.info(
`Bots queue is off in this process: neither MAIN_SERVER nor development mode is set.`,
);
return; return;
} }
bunext.bunextLog.watch(`Running Bots Queue ...`); bunext.bunextLog.watch(`Running Bots Queue ...`);
const released = await releaseWorkerBotJobs({ worker }); /**
* Releasing the jobs a restart cut off must never stop the queue: if
* it fails, polling still starts, and the lock timeout releases them.
*/
try {
const released = await releaseWorkerBotJobs({ worker });
if (released) { if (released) {
bunext.bunextLog.info(`Bots queue: released ${released} cut-off job(s)`); bunext.bunextLog.info(`Bots queue: released ${released} cut-off job(s)`);
}
} catch (error) {
await logError({
title: "Bots queue couldn't release cut-off jobs",
error,
}).catch(() => {});
} }
setBotsQueueWaker({ waker: wake }); setBotsQueueWaker({ waker: wake });
+33 -7
View File
@@ -7,6 +7,7 @@ import updateBotJob from "@/src/functions/backend/db/bots-queue/update-bot-job";
import insertBotJobUpdate from "@/src/functions/backend/db/bots-queue/insert-bot-job-update"; import insertBotJobUpdate from "@/src/functions/backend/db/bots-queue/insert-bot-job-update";
import { logError } from "@/src/functions/backend/logger"; import { logError } from "@/src/functions/backend/logger";
import createKeyLock from "@/src/utils/create-key-lock"; import createKeyLock from "@/src/utils/create-key-lock";
import withTimeout from "@/src/utils/with-timeout";
import HarnessError from "@/src/harness/harness-error"; import HarnessError from "@/src/harness/harness-error";
import deferBotJob from "@/src/functions/backend/db/bots-queue/defer-bot-job"; import deferBotJob from "@/src/functions/backend/db/bots-queue/defer-bot-job";
import recordCronJobFailure from "@/src/functions/backend/db/cron-jobs/record-cron-job-failure"; import recordCronJobFailure from "@/src/functions/backend/db/cron-jobs/record-cron-job-failure";
@@ -85,6 +86,13 @@ export default async function processBotJob({
await pushBotNotice({ bot_id, bot: await setBotFace({ bot_id, user_id, ...face }) }); await pushBotNotice({ bot_id, bot: await setBotFace({ bot_id, user_id, ...face }) });
}; };
const controller = new AbortController(); const controller = new AbortController();
/**
* The deadline is the one abort that is a failure, not a cancellation:
* the job's error is then the timeout's, not the "Cancelled." an
* aborted model request reports.
*/
const timeout_message = `"${job.title || "The job"}" took too long and was stopped.`;
let timed_out = false;
const update: BotJobContext["update"] = async (params) => { const update: BotJobContext["update"] = async (params) => {
if (controller.signal.aborted) return false; if (controller.signal.aborted) return false;
@@ -137,13 +145,27 @@ export default async function processBotJob({
status_line: job.kind == "reply" ? BotReplyJobTitle : BotTaskStatusLine, status_line: job.kind == "reply" ? BotReplyJobTitle : BotTaskStatusLine,
}); });
return await runner({ /**
job, * A deadline per attempt: the heartbeat keeps the lock alive
params, * for as long as this run takes, so without one a call that
checkpoint: parseJobJson({ text: job.checkpoint }), * never answers would hold the slot forever.
*/
return await withTimeout({
timeout_ms: AppData.BotJobAttemptTimeoutMs,
signal: controller.signal, signal: controller.signal,
update, message: timeout_message,
report, on_timeout: () => {
timed_out = true;
},
task: (run_signal) =>
runner({
job,
params,
checkpoint: parseJobJson({ text: job.checkpoint }),
signal: run_signal,
update,
report,
}),
}); });
}; };
@@ -229,7 +251,11 @@ export default async function processBotJob({
return; return;
} }
const message = error instanceof Error ? error.message : String(error); const message = timed_out
? timeout_message
: error instanceof Error
? error.message
: String(error);
const tries = Number(job.attempts) || 1; const tries = Number(job.attempts) || 1;
const failed = await failBotJob({ const failed = await failBotJob({
+15 -1
View File
@@ -1,5 +1,6 @@
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotFaceChange } from "@/src/functions/backend/db/bots/set-bot-face"; import type { BotFaceChange } from "@/src/functions/backend/db/bots/set-bot-face";
import { logError } from "@/src/functions/backend/logger";
import notifyBotMessage from "@/src/push/notify-bot-message"; import notifyBotMessage from "@/src/push/notify-bot-message";
import notifyBotFace from "@/src/push/notify-bot-face"; import notifyBotFace from "@/src/push/notify-bot-face";
import pushBotUpdated from "./push-bot-updated"; import pushBotUpdated from "./push-bot-updated";
@@ -23,9 +24,22 @@ type Params = {
* Pushes a new bot message to the open tabs of the user whose * Pushes a new bot message to the open tabs of the user whose
* conversation it's in, and a face change to the user it's for * conversation it's in, and a face change to the user it's for
* (`pushBotUpdated`). A relayed report * (`pushBotUpdated`). A relayed report
* goes to the conversation it was posted in. * goes to the conversation it was posted in. Best effort: a push that
* fails is logged and never fails the job that made the change.
*/ */
export default async function pushBotNotice({ bot_id, message, bot, relay }: Params) { export default async function pushBotNotice({ bot_id, message, bot, relay }: Params) {
try {
await push({ bot_id, message, bot, relay });
} catch (error) {
await logError({
title: "Bot notice wasn't pushed",
error,
metadata: { bot_id, message_id: message?.id || null },
}).catch(() => {});
}
}
async function push({ bot_id, message, bot, relay }: Params) {
if (message) { if (message) {
await pushToSessionUser({ await pushToSessionUser({
session_id: message.session_id, session_id: message.session_id,
+21 -1
View File
@@ -1,4 +1,5 @@
import grabBotAudience from "@/src/functions/backend/db/access/grab-bot-audience"; import grabBotAudience from "@/src/functions/backend/db/access/grab-bot-audience";
import { logError } from "@/src/functions/backend/logger";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task"; import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
import grabBotFace from "@/src/functions/backend/db/bots/grab-bot-face"; import grabBotFace from "@/src/functions/backend/db/bots/grab-bot-face";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user"; import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
@@ -29,7 +30,8 @@ type Params = {
/** /**
* Pushes `server:bot-updated` to one user, or everyone who sees the bot * Pushes `server:bot-updated` to one user, or everyone who sees the bot
* (`grabBotAudience`), each with their own face, status line and active * (`grabBotAudience`), each with their own face, status line and active
* task with it, never another user's. * task with it, never another user's. Best effort: a push that fails is
* logged and never fails the change that caused it.
*/ */
export default async function pushBotUpdated({ export default async function pushBotUpdated({
bot_id, bot_id,
@@ -40,6 +42,24 @@ export default async function pushBotUpdated({
}: Params) { }: Params) {
if (!bot_id) return; if (!bot_id) return;
try {
await push({ bot_id, user_id, users, computer_id, workspace_ids });
} catch (error) {
await logError({
title: "Bot update wasn't pushed",
error,
metadata: { bot_id },
}).catch(() => {});
}
}
async function push({
bot_id,
user_id,
users,
computer_id,
workspace_ids,
}: Params) {
const audience = user_id ? [user_id] : await grabBotAudience({ bot_id }); const audience = user_id ? [user_id] : await grabBotAudience({ bot_id });
for (const viewer_id of audience) { for (const viewer_id of audience) {
+15
View File
@@ -122,6 +122,14 @@ export const AppData = {
BotQueueMaxAttempts: 3, BotQueueMaxAttempts: 3,
BotQueueLockTimeoutMs: 10 * 60 * 1000, BotQueueLockTimeoutMs: 10 * 60 * 1000,
BotQueueBackoffMs: 30 * 1000, BotQueueBackoffMs: 30 * 1000,
/**
* One attempt of a job (one model run or task, tools included) gives up
* after this long and counts as a failed try, so a call that hangs
* without ever answering can't hold the job, its computer or the
* conversation forever. The lock is outlived by the heartbeat, so this
* is the only thing that stops a live but stuck run.
*/
BotJobAttemptTimeoutMs: 60 * 60 * 1000,
/** /**
* A failed scheduled run is tried again after this, doubling each time * A failed scheduled run is tried again after this, doubling each time
* (`scheduledRunRetryAt`), so a short outage doesn't sink the run. * (`scheduledRunRetryAt`), so a short outage doesn't sink the run.
@@ -170,6 +178,13 @@ export const AppData = {
*/ */
HarnessMaxCandidates: 5, HarnessMaxCandidates: 5,
HarnessRequestTimeoutMs: 3 * 60 * 1000, HarnessRequestTimeoutMs: 3 * 60 * 1000,
/**
* One tool call gives up after this long, whatever the tool is (bot
* tools, MCP servers, computers, custom tools), so a tool that ignores
* its own limits can't stall the run. Its result comes back as an error
* the model can work around.
*/
HarnessToolTimeoutMs: 10 * 60 * 1000,
/** /**
* A model that rate limits or fails is skipped for this long, unless * A model that rate limits or fails is skipped for this long, unless
* the provider says when to come back (capped at the max). * the provider says when to come back (capped at the max).
@@ -2,12 +2,38 @@ import { AppData } from "@/src/data/app-data";
import dockerRequest from "./docker-request"; import dockerRequest from "./docker-request";
import grabAppContainer from "./grab-app-container"; import grabAppContainer from "./grab-app-container";
/**
* Docker can still be coming up when the app container starts (a host
* reboot, especially), so a transient failure is retried before the app
* gives up on networking.
*/
const Attempts = 3;
const RetryMs = 2000;
/** /**
* Makes sure the network computers run on (`AppData.DockerNetworkName`) * Makes sure the network computers run on (`AppData.DockerNetworkName`)
* exists and, when the app runs in docker, that its container is on it, * exists and, when the app runs in docker, that its container is on it,
* so it reaches computers by name. * so it reaches computers by name.
*/ */
export default async function ensureDockerNetwork() { export default async function ensureDockerNetwork() {
let last_error: unknown;
for (let attempt = 1; attempt <= Attempts; attempt++) {
try {
return await connectNetwork();
} catch (error: unknown) {
last_error = error;
if (attempt < Attempts) {
await new Promise((resolve) => setTimeout(resolve, RetryMs));
}
}
}
throw last_error;
}
async function connectNetwork() {
const name = AppData.DockerNetworkName; const name = AppData.DockerNetworkName;
const existing = await dockerRequest({ path: `/networks/${encodeURIComponent(name)}` }); const existing = await dockerRequest({ path: `/networks/${encodeURIComponent(name)}` });
@@ -1,3 +1,4 @@
import { AppData } from "@/src/data/app-data";
import HarnessError from "../../harness-error"; import HarnessError from "../../harness-error";
import type { HarnessAdapter, HarnessImageOutput } from "../../types"; import type { HarnessAdapter, HarnessImageOutput } from "../../types";
import grabProviderPreset from "../grab-provider-preset"; import grabProviderPreset from "../grab-provider-preset";
@@ -54,7 +55,12 @@ const generateImage: HarnessAdapter["generateImage"] = async ({
media_type: item.media_type || "image/png", media_type: item.media_type || "image/png",
}); });
} else if (typeof item?.url == "string") { } else if (typeof item?.url == "string") {
const download = await fetch(item.url, { signal }); const download = await fetch(item.url, {
signal: AbortSignal.any([
signal,
AbortSignal.timeout(AppData.HarnessRequestTimeoutMs),
]),
});
if (download.ok) { if (download.ok) {
images.push({ images.push({
@@ -1,4 +1,5 @@
import guessModelModalities from "../../catalog/guess-model-modalities"; import guessModelModalities from "../../catalog/guess-model-modalities";
import HarnessError from "../../harness-error";
import type { HarnessAdapter, HarnessCatalogModel } from "../../types"; import type { HarnessAdapter, HarnessCatalogModel } from "../../types";
import grabProviderPreset from "../grab-provider-preset"; import grabProviderPreset from "../grab-provider-preset";
import openRouterModelToCatalog, { import openRouterModelToCatalog, {
@@ -27,6 +28,18 @@ const listModels: HarnessAdapter["listModels"] = async ({
}); });
const json = await res.json().catch(() => null); const json = await res.json().catch(() => null);
/**
* An unreadable list is a failed sync, not an empty catalog: keeping
* the models already known beats marking them all unavailable.
*/
if (json === null) {
throw new HarnessError({
message: `${provider.name || preset.title} returned an unreadable model list.`,
retryable: true,
});
}
const items: any[] = Array.isArray(json?.data) const items: any[] = Array.isArray(json?.data)
? json.data ? json.data
: Array.isArray(json?.models) : Array.isArray(json?.models)
@@ -1,3 +1,4 @@
import HarnessError from "../../harness-error";
import type { HarnessAdapter } from "../../types"; import type { HarnessAdapter } from "../../types";
import providerRequest from "./provider-request"; import providerRequest from "./provider-request";
@@ -36,6 +37,19 @@ const transcribe: HarnessAdapter["transcribe"] = async ({
const json = await res.json().catch(() => null); const json = await res.json().catch(() => null);
/**
* A cut-off or malformed answer is a failed call, not silence: saying
* it failed lets the harness try the next model instead of handing the
* user an empty transcript.
*/
if (json === null) {
throw new HarnessError({
message: `${candidate.provider.name || "The provider"} returned an unreadable transcript.`,
retryable: true,
candidate_key: candidate.key,
});
}
return { text: String(json?.text || "").trim() }; return { text: String(json?.text || "").trim() };
}; };
+17 -2
View File
@@ -1,5 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import TaskTimeoutError from "@/src/utils/task-timeout-error";
import withTimeout from "@/src/utils/with-timeout";
import HarnessError from "../harness-error"; import HarnessError from "../harness-error";
import type { HarnessTool, HarnessToolCall } from "../types"; import type { HarnessTool, HarnessToolCall } from "../types";
@@ -40,14 +42,27 @@ export default async function runToolCall({ call, tools, signal }: Params) {
} }
try { try {
return truncate({ const text = await withTimeout({
text: await tool.run({ args: parsed ? parsed.data : raw, signal }), timeout_ms: AppData.HarnessToolTimeoutMs,
signal,
message: `${call.name} took too long and was stopped.`,
task: (tool_signal) =>
tool.run({
args: parsed ? parsed.data : raw,
signal: tool_signal,
}),
}); });
return truncate({ text });
} catch (error: unknown) { } catch (error: unknown) {
if (signal.aborted) { if (signal.aborted) {
throw new HarnessError({ message: `Cancelled.`, code: "aborted" }); throw new HarnessError({ message: `Cancelled.`, code: "aborted" });
} }
if (error instanceof TaskTimeoutError) {
return `Error: ${error.message}`;
}
return `Error: ${error instanceof Error ? error.message : String(error)}`; return `Error: ${error instanceof Error ? error.message : String(error)}`;
} }
} }
+23 -8
View File
@@ -20,14 +20,29 @@ import type { LiveRelayCursor } from "./types";
* Runs in every server process, ungated. * Runs in every server process, ungated.
*/ */
export default async function liveRelay() { export default async function liveRelay() {
const latest = async (table: "bot_messages" | "bots_queue_updates") => /**
Number( * A failed read starts the relay from the beginning instead of not
( * starting it: clients drop what they already have.
await BunSQLite.sql<{ id: number }>({ */
sql: `SELECT MAX(id) AS id FROM ${table}`, const latest = async (table: "bot_messages" | "bots_queue_updates") => {
}) try {
).payload?.[0]?.id, return (
) || 0; Number(
(
await BunSQLite.sql<{ id: number }>({
sql: `SELECT MAX(id) AS id FROM ${table}`,
})
).payload?.[0]?.id,
) || 0
);
} catch (error) {
await logError({
title: `Live relay couldn't read ${table}`,
error,
}).catch(() => {});
return 0;
}
};
const cursor: LiveRelayCursor = { const cursor: LiveRelayCursor = {
message_id: await latest("bot_messages"), message_id: await latest("bot_messages"),
+16 -3
View File
@@ -200,6 +200,19 @@ if (tls) {
} }
} }
cron(); /**
botsQueue(); * Background services start after the server is up, and one failing to
liveRelay(); * start never takes the server (or the others) down with it.
*/
try {
cron();
} catch (error) {
logError({ title: "Cron couldn't start", error }).catch(() => {});
}
botsQueue().catch((error) =>
logError({ title: "Bots queue couldn't start", error }).catch(() => {}),
);
liveRelay().catch((error) =>
logError({ title: "Live relay couldn't start", error }).catch(() => {}),
);
+10
View File
@@ -0,0 +1,10 @@
/**
* A task was stopped because it ran past its deadline (`withTimeout`).
* Its message is safe to show: it says what ran too long, not internals.
*/
export default class TaskTimeoutError extends Error {
constructor({ message }: { message: string }) {
super(message);
this.name = "TaskTimeoutError";
}
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, test } from "bun:test";
import TaskTimeoutError from "./task-timeout-error";
import withTimeout from "./with-timeout";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("withTimeout", () => {
test("returns the task's result", async () => {
const result = await withTimeout({
timeout_ms: 100,
task: async () => "done",
});
expect(result).toBe("done");
});
test("stops a task that runs past its deadline", async () => {
const error = await withTimeout({
timeout_ms: 10,
message: "Too slow.",
task: () => wait(200),
}).catch((e) => e);
expect(error).toBeInstanceOf(TaskTimeoutError);
expect(error instanceof TaskTimeoutError && error.message).toBe("Too slow.");
});
test("ends the wait even when the task ignores its signal", async () => {
const error = await withTimeout({
timeout_ms: 10,
task: () => wait(200),
}).catch((e) => e);
expect(error).toBeInstanceOf(TaskTimeoutError);
});
test("aborts the task's signal on the deadline", async () => {
let aborted = false;
await withTimeout({
timeout_ms: 10,
task: async (task_signal) => {
task_signal.addEventListener("abort", () => {
aborted = true;
});
await wait(50);
},
}).catch(() => {});
expect(aborted).toBe(true);
});
test("passes the caller's cancellation through", async () => {
const controller = new AbortController();
let aborted = false;
let started: () => void = () => {};
const running_task = new Promise<void>((resolve) => {
started = resolve;
});
const running = withTimeout({
timeout_ms: 1000,
signal: controller.signal,
task: async (task_signal) => {
task_signal.addEventListener("abort", () => {
aborted = true;
});
started();
await wait(200);
},
});
await running_task;
controller.abort();
const error = await running.catch((e) => e);
expect(error).toBeInstanceOf(Error);
expect(aborted).toBe(true);
});
test("doesn't start a task when already cancelled", async () => {
const controller = new AbortController();
let started = false;
controller.abort();
const error = await withTimeout({
timeout_ms: 100,
signal: controller.signal,
task: async () => {
started = true;
},
}).catch((e) => e);
expect(error).toBeInstanceOf(Error);
expect(started).toBe(false);
});
});
+85
View File
@@ -0,0 +1,85 @@
import TaskTimeoutError from "./task-timeout-error";
type Params<T> = {
/**
* The work. It gets a signal that aborts when the deadline passes or
* the caller cancels, so its requests stop too.
*/
task: (signal: AbortSignal) => Promise<T>;
timeout_ms: number;
/**
* The caller's own cancellation, e.g. a job that lost its lock.
*/
signal?: AbortSignal;
/**
* Shown when the deadline passes.
*/
message?: string;
/**
* Called once the deadline passes, before the task is stopped. Use it
* to tell a deadline apart from the caller's own cancellation.
*/
on_timeout?: () => void;
};
/**
* Runs a task with a deadline, resolving or rejecting with whatever the
* task does. When the deadline passes (or the caller's signal aborts) the
* task's signal is aborted and the wait ends at once, even if the task
* never settles, so a hung call can't hold a queue slot or a lock forever.
* Late results and errors from the task are dropped.
*/
export default function withTimeout<T>({
task,
timeout_ms,
signal,
message = "The task took too long.",
on_timeout,
}: Params<T>): Promise<T> {
if (signal?.aborted) {
return Promise.reject(reasonOf({ signal }));
}
const controller = new AbortController();
const parent = signal;
const on_abort = () => controller.abort(reasonOf({ signal: parent! }));
parent?.addEventListener("abort", on_abort, { once: true });
const timer = setTimeout(() => {
on_timeout?.();
controller.abort(new TaskTimeoutError({ message }));
}, timeout_ms);
const done = new Promise<T>((resolve, reject) => {
controller.signal.addEventListener(
"abort",
() => reject(controller.signal.reason),
{ once: true },
);
/**
* `Promise.resolve().then` turns a synchronous throw from the task
* into a rejection instead of escaping the executor. A task
* cancelled before it starts is never started.
*/
Promise.resolve()
.then(() => {
if (controller.signal.aborted) {
throw reasonOf({ signal: controller.signal });
}
return task(controller.signal);
})
.then(resolve, reject);
});
return done.finally(() => {
clearTimeout(timer);
parent?.removeEventListener("abort", on_abort);
});
}
function reasonOf({ signal }: { signal: AbortSignal }) {
return signal.reason instanceof Error ? signal.reason : new Error("Cancelled.");
}