From fcad4b49d6e3ae8b38a6ce5c9ccedafa1f6cf9ee Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Fri, 23 Oct 2026 10:19:41 +0100 Subject: [PATCH] Let a user stop a bot, and say the app is still alpha The thinking row and the header menu both carry Stop while a bot has work open for the user anywhere: every pending or processing job of that bot in their conversations is failed as stopped, its task closed, a Stopped step recorded and one closing message posted per session, so the thread stops waiting and the bot goes idle. The handler running the job is aborted from this process through a registry, so a job stuck waiting on a model stops at once instead of on its next heartbeat, and a late face change after the abort is dropped. The release stage is now said in the app too: Settings > General reports "Cobble v0.1.5 (alpha)" next to development, and the README and RELEASING.md say what alpha means for installs. --- AGENTS.md | 4 +- README.md | 10 +- RELEASING.md | 6 + src/bots-queue/job-abort-registry.test.ts | 58 ++++++ src/bots-queue/job-abort-registry.ts | 40 ++++ src/bots-queue/process-bot-job.ts | 26 ++- src/bots-queue/push-bot-notice.ts | 16 +- src/cron/heartbeat/index.ts | 6 +- src/cron/sync-computers/index.ts | 13 +- src/dict/bot-queue-dict.ts | 24 +++ .../db/bots-queue/open-bot-jobs-sql.test.ts | 69 +++++++ .../db/bots-queue/open-bot-jobs-sql.ts | 29 +++ .../db/bots-queue/stop-bot-jobs.test.ts | 31 +++ .../backend/db/bots-queue/stop-bot-jobs.ts | 130 ++++++++++++ .../db/bots-queue/stopped-job-report.test.ts | 79 +++++++ .../db/bots-queue/stopped-job-report.ts | 45 ++++ .../bots/[bot_id]/(data)/stop-bot-context.ts | 21 ++ .../bots/[bot_id]/(functions)/stop-bot.ts | 19 ++ .../bots/[bot_id]/(hooks)/use-stop-bot.ts | 65 ++++++ .../[bot_id]/(partials)/bot-thinking-row.tsx | 30 ++- .../(partials)/conversation-header-menu.tsx | 8 +- .../[bot_id]/(partials)/stop-bot-button.tsx | 79 +++++++ .../bots/[bot_id]/(sections)/conversation.tsx | 37 +++- .../[bot_id]/(utils)/bot-busy-state.test.ts | 193 ++++++++++++++++++ .../bots/[bot_id]/(utils)/bot-busy-state.ts | 111 ++++++++++ .../general/(partials)/version-line.tsx | 20 +- .../general/(utils)/version-label.test.ts | 30 +++ .../settings/general/(utils)/version-label.ts | 27 +++ src/pages/api/admin/bots/stop.ts | 99 +++++++++ src/types/index.ts | 11 + 30 files changed, 1279 insertions(+), 57 deletions(-) create mode 100644 src/bots-queue/job-abort-registry.test.ts create mode 100644 src/bots-queue/job-abort-registry.ts create mode 100644 src/functions/backend/db/bots-queue/open-bot-jobs-sql.test.ts create mode 100644 src/functions/backend/db/bots-queue/open-bot-jobs-sql.ts create mode 100644 src/functions/backend/db/bots-queue/stop-bot-jobs.test.ts create mode 100644 src/functions/backend/db/bots-queue/stop-bot-jobs.ts create mode 100644 src/functions/backend/db/bots-queue/stopped-job-report.test.ts create mode 100644 src/functions/backend/db/bots-queue/stopped-job-report.ts create mode 100644 src/pages/admin/bots/[bot_id]/(data)/stop-bot-context.ts create mode 100644 src/pages/admin/bots/[bot_id]/(functions)/stop-bot.ts create mode 100644 src/pages/admin/bots/[bot_id]/(hooks)/use-stop-bot.ts create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/stop-bot-button.tsx create mode 100644 src/pages/admin/bots/[bot_id]/(utils)/bot-busy-state.test.ts create mode 100644 src/pages/admin/bots/[bot_id]/(utils)/bot-busy-state.ts create mode 100644 src/pages/admin/settings/general/(utils)/version-label.test.ts create mode 100644 src/pages/admin/settings/general/(utils)/version-label.ts create mode 100644 src/pages/api/admin/bots/stop.ts diff --git a/AGENTS.md b/AGENTS.md index 8c9827b..9acc454 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,14 +105,14 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl - **Composer:** a Tiptap editor (`(hooks)/use-composer-editor.ts`, `(partials)/composer-editor.tsx`), not a textarea, loaded with the page and focused automatically, that reads and writes markdown (`@tiptap/markdown`), so drafts and sent messages are markdown. Drafts are written to `localStorage` on a 250ms debounce (`useComposerDraft`), flushed on unmount. Bold, italic, strike, code, lists, quotes and links (no headings), from shortcuts, markdown typing or the selection bubble (`composer-format-bubble.tsx`, items in `(data)/composer-formats.ts`). `@` mentions a bot and `#` a workspace (`(data)/composer-mention-kinds.ts`, menu `composer-mention-list.tsx` above the input, items from `CrewBotsContext` and `CrewWorkspacesContext` via `searchMentionItems`), shown as chips (`composer-mention-chip.tsx`) and written as `[bot:ID]` / `[workspace:ID]` (`(utils)/mention-markdown.ts`, `src/utils/workspace-ref.ts`). Enter sends, Shift+Enter breaks the line; in a list or code block Enter is the editor's and Mod+Enter sends. Sending shows the bubble at once and empties the input (with its quote and files), so a slow connection never waits on the request: the optimistic message rides in `pending_messages` in `conversation.tsx` until the response or a push replaces it with the saved one (`(utils)/drop-sent-message.ts`), and a failed send takes it back and restores the text and files. Selecting text in a message (`data-quotable`, `use-message-selection`) shows a Quote button (`quote-selection-button.tsx`) that inserts it as a blockquote at the cursor (`ComposerHandle.insertQuote`). Before a reply, `grabMessageRefNames` names the refs in the user's messages that the user reaches, and `botMessagesToHarness` adds a `[Mentions: ...]` line after each one (`describeMessageRefs`). `@tiptap/react` needs `use-sync-external-store`, which is CommonJS and calls `require("react")`; bunext keeps React external in page bundles, so the app installs an ESM stand-in from `packages/use-sync-external-store-esm` (a `file:` dependency plus an override; the Dockerfile copies `packages/` before `bun install`). - **Message rendering:** bot messages are markdown, rendered with `react-markdown` + `remark-gfm` (raw HTML never rendered) through the twui-based map in `[bot_id]/(data)/message-markdown-components.tsx` (`markdown-link`, `markdown-list`, `markdown-code-block` partials). A bot names a crew member as `[bot:ID]` (`src/utils/bot-ref.ts`, linked to `#bot-ID` by `link-computer-refs.ts`): `(partials)/bot-ref-chip.tsx` shows its avatar and name, linked to its conversation (in the open workspace when it's in the crew there), from the live crew (`CrewBotsContext`), so a bot created in that reply links too; the coordinator is told to write them (`CoordinatorReplyNote`), and `find_bot` and `create_bot` return them. A workspace is named as `[workspace:ID]` (`#workspace-ID`, `(partials)/workspace-ref-chip.tsx`, from `CrewWorkspacesContext`). User messages are markdown too, rendered in the ink bubble by `user-bubble.tsx` with `UserMarkdownComponents` and `remark-breaks` (a single line break stays one, so older plain messages read as typed). - **Replies and reactions:** every bot message has a row under it (`(partials)/message-actions.tsx`, handlers from `BotMessageActionsContext`) to reply or react; the user's own messages only show the bot's reactions, read-only. Replying quotes the message above the composer (`composer-reply-preview.tsx`) and the sent one carries `bot_messages.reply_to_message_id` (not a foreign key: a deleted quote only drops it); `send-message` takes any message of the same conversation (the user's, same bot and workspace, any session, so a rollover or a moved-on thread still sends), and the conversation drops a picked quote when its session changes. Messages carry `reply_to` (`grabMessagesReplies`, also on relayed messages), shown by `message-reply-quote.tsx`, and the bot reads it as `[Replying to ...]` before the user's turn (`botMessagesToHarness`). Reactions are a fixed set (`BotMessageReactions` in `src/dict/bot-dict.ts`) in `bot_message_reactions` (one row per message, emoji and user, or bot); `api/admin/bots/react-message.ts` toggles the user's own on a bot message in their sessions (`grabUserBotMessage`, `toggleMessageReaction`), and every bot gets `react_to_message` (`src/harness/tools/bot/react-to-message-tool.ts`, chat replies and tasks: a message in the session it answers, the user's latest by default). Both push the message's fresh reactions (`grabMessagesReactions`, `mine` for the session user) to the session's user as `server:bot-reaction` (`BotMessageReactionData`), which replaces the message's own; `grabBotMessages` adds them when given `user_id`. Reactions aren't relayed between processes (production runs one). -- **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While the user has the last word in a session (every user message queues a reply, so this needs no face and shows even while the bot works in another session) or the bot waits for a model (`BotWaitingStatusLine`) or a task for its computer (`BotComputerBusyStatusLine`), `BotThinkingRow` sits under the last message; a message still on its way to the server reads " is getting your message…" (`BotGettingMessageStatusLine`), while a task runs it reads " is working…" with the task's latest step, and a reply working through tools shows its latest step under "is thinking…" (`use-reply-progress`). A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay. +- **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While the user has the last word in a session (every user message queues a reply, so this needs no face and shows even while the bot works in another session) or the bot waits for a model (`BotWaitingStatusLine`) or a task for its computer (`BotComputerBusyStatusLine`), `BotThinkingRow` sits under the last message; a message still on its way to the server reads " is getting your message…" (`BotGettingMessageStatusLine`), while a task runs it reads " is working…" with the task's latest step, and a reply working through tools shows its latest step under "is thinking…" (`use-reply-progress`). A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. A stop button rides on that row (`stop-bot-button.tsx`, and the header menu's Stop row, both through `StopBotContext`) exactly while `botBusyState` (`src/pages/admin/bots/[bot_id]/(utils)/bot-busy-state.ts`, which also decides the row's line) says the bot has work open for that user anywhere; a message still on its way to the server isn't stoppable, since its reply is queued after it lands. Live notices come from the process that ran the job at once, and from every other process through the live relay. - **Voice:** the composer's mic (`(partials)/composer-voice-button.tsx`, `use-voice-recorder`, shown once the browser can record, disabled with a note while transcription has no model to run on for the bot there, `bot_voice` page prop, from `resolveHarnessCandidates`) dictates: the recording goes to `api/admin/bots/transcribe.ts` (`transcribeAudio` with the bot's models, after `grabUserConversation`) and the text lands in the input at the cursor, to edit and send; Escape cancels, `AppData.ComposerVoiceMaxMs` stops it. Each bot message has a play button (disabled with a note while speech has no model) (`speak-message-button.tsx`, one player per conversation via `SpeakMessageContext` / `use-speak-message`) that plays `GET api/admin/bots/speak-message.ts?message_id=` straight in an `