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.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { abortRunningJob, registerJobAbort, unregisterJobAbort } from "./job-abort-registry";
|
||||
|
||||
describe("job-abort-registry", () => {
|
||||
test("aborts the job this process is running", () => {
|
||||
let aborted = false;
|
||||
|
||||
registerJobAbort({ job_id: 101, abort: () => (aborted = true) });
|
||||
|
||||
expect(abortRunningJob({ job_id: 101 })).toBe(true);
|
||||
expect(aborted).toBe(true);
|
||||
|
||||
unregisterJobAbort({ job_id: 101 });
|
||||
});
|
||||
|
||||
test("a job running in another process is not here, so it isn't reported as aborted", () => {
|
||||
expect(abortRunningJob({ job_id: 202 })).toBe(false);
|
||||
});
|
||||
|
||||
test("a finished job is cleaned up, so its id can't abort a later one", () => {
|
||||
let aborted = false;
|
||||
|
||||
registerJobAbort({ job_id: 303, abort: () => (aborted = true) });
|
||||
unregisterJobAbort({ job_id: 303 });
|
||||
|
||||
expect(abortRunningJob({ job_id: 303 })).toBe(false);
|
||||
expect(aborted).toBe(false);
|
||||
});
|
||||
|
||||
test("a numeric string id reaches the same entry, since a route may send one", () => {
|
||||
let aborted = false;
|
||||
|
||||
registerJobAbort({ job_id: 404, abort: () => (aborted = true) });
|
||||
|
||||
expect(abortRunningJob({ job_id: "404" })).toBe(true);
|
||||
expect(aborted).toBe(true);
|
||||
|
||||
unregisterJobAbort({ job_id: 404 });
|
||||
});
|
||||
|
||||
test("unregistering a job nobody registered is harmless", () => {
|
||||
expect(() => unregisterJobAbort({ job_id: 505 })).not.toThrow();
|
||||
});
|
||||
|
||||
test("only one job is aborted at a time", () => {
|
||||
const aborted: number[] = [];
|
||||
|
||||
registerJobAbort({ job_id: 606, abort: () => aborted.push(606) });
|
||||
registerJobAbort({ job_id: 707, abort: () => aborted.push(707) });
|
||||
|
||||
abortRunningJob({ job_id: 606 });
|
||||
|
||||
expect(aborted).toEqual([606]);
|
||||
|
||||
unregisterJobAbort({ job_id: 606 });
|
||||
unregisterJobAbort({ job_id: 707 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* The abort controllers of the jobs this process is running right now,
|
||||
* keyed by job id. Stopping a bot from the chat aborts the request its job
|
||||
* is waiting on (a model that never answers, a tool that hangs) instead of
|
||||
* leaving it to find out on its next heartbeat, which can take minutes.
|
||||
*
|
||||
* Only the process running a job can abort it, so this is a bonus: the job
|
||||
* is already failed in `bots_queue`, and a handler in another process
|
||||
* learns that on its next `updateBotJob`. Kept free of imports so the API
|
||||
* routes can use it without pulling the queue handler into a page bundle.
|
||||
*/
|
||||
const running = new Map<number, () => void>();
|
||||
|
||||
/**
|
||||
* Called by a handler once it holds a job, with the way to abort it.
|
||||
*/
|
||||
export function registerJobAbort({ job_id, abort }: { job_id: number | string; abort: () => void }) {
|
||||
running.set(Number(job_id), abort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by a handler in its `finally`, whatever the outcome.
|
||||
*/
|
||||
export function unregisterJobAbort({ job_id }: { job_id: number | string }) {
|
||||
running.delete(Number(job_id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts a job running here. False means another process (or nobody) has
|
||||
* it, so the row's new status is how it finds out.
|
||||
*/
|
||||
export function abortRunningJob({ job_id }: { job_id: number | string }) {
|
||||
const abort = running.get(Number(job_id));
|
||||
|
||||
if (!abort) return false;
|
||||
|
||||
abort();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import runBotJob from "./run-bot-job";
|
||||
import parseJobJson from "./parse-job-json";
|
||||
import nameBotSession from "./name-bot-session";
|
||||
import pushBotSession from "./push-bot-session";
|
||||
import { registerJobAbort, unregisterJobAbort } from "./job-abort-registry";
|
||||
import type { BotJobUpdateData } from "@/src/types";
|
||||
import type { BotJobContext, BotJobRunner } from "./types";
|
||||
|
||||
@@ -80,11 +81,6 @@ export default async function processBotJob({
|
||||
*/
|
||||
const user_id = await grabJobUserId({ job }).catch(() => null);
|
||||
|
||||
const setFace = async (face: Omit<Parameters<typeof setBotFace>[0], "bot_id" | "user_id">) => {
|
||||
if (!user_id) return;
|
||||
|
||||
await pushBotNotice({ bot_id, bot: await setBotFace({ bot_id, user_id, ...face }) });
|
||||
};
|
||||
const controller = new AbortController();
|
||||
/**
|
||||
* The deadline is the one abort that is a failure, not a cancellation:
|
||||
@@ -94,6 +90,25 @@ export default async function processBotJob({
|
||||
const timeout_message = `"${job.title || "The job"}" took too long and was stopped.`;
|
||||
let timed_out = false;
|
||||
|
||||
/**
|
||||
* A stopped job never touches the face again: `stopBotJobs` already set
|
||||
* it to idle, and a late `working` or `asleep` would push over that and
|
||||
* leave a bot that shows work with nothing behind it.
|
||||
*/
|
||||
const setFace = async (face: Omit<Parameters<typeof setBotFace>[0], "bot_id" | "user_id">) => {
|
||||
if (!user_id || controller.signal.aborted) return;
|
||||
|
||||
await pushBotNotice({ bot_id, bot: await setBotFace({ bot_id, user_id, ...face }) });
|
||||
};
|
||||
|
||||
/**
|
||||
* Stopping the bot from the chat aborts the request this job is
|
||||
* waiting on, here and now, rather than at the next heartbeat. The job
|
||||
* is already failed in the queue, so an abort is a cancellation and its
|
||||
* result is dropped (`controller.signal.aborted` below).
|
||||
*/
|
||||
registerJobAbort({ job_id, abort: () => controller.abort() });
|
||||
|
||||
const update: BotJobContext["update"] = async (params) => {
|
||||
if (controller.signal.aborted) return false;
|
||||
|
||||
@@ -305,5 +320,6 @@ export default async function processBotJob({
|
||||
}
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
unregisterJobAbort({ job_id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ type Params = {
|
||||
* (`relayJobToRequester`): a message in that bot's conversation.
|
||||
*/
|
||||
relay?: { bot_id: number; message: BUN_SQLITE_COBBLE_BOT_MESSAGES } | null;
|
||||
/**
|
||||
* Skip the push notification, keeping only the live send. For a message
|
||||
* the user just caused themselves (stopping the bot): they are looking
|
||||
* at the app, and one notification per conversation would be noise.
|
||||
*/
|
||||
quiet_push?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,9 +33,9 @@ type Params = {
|
||||
* 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, quiet_push }: Params) {
|
||||
try {
|
||||
await push({ bot_id, message, bot, relay });
|
||||
await push({ bot_id, message, bot, relay, quiet_push });
|
||||
} catch (error) {
|
||||
await logError({
|
||||
title: "Bot notice wasn't pushed",
|
||||
@@ -39,7 +45,7 @@ export default async function pushBotNotice({ bot_id, message, bot, relay }: Par
|
||||
}
|
||||
}
|
||||
|
||||
async function push({ bot_id, message, bot, relay }: Params) {
|
||||
async function push({ bot_id, message, bot, relay, quiet_push }: Params) {
|
||||
if (message) {
|
||||
await pushToSessionUser({
|
||||
session_id: message.session_id,
|
||||
@@ -49,7 +55,7 @@ async function push({ bot_id, message, bot, relay }: Params) {
|
||||
/**
|
||||
* Not waited on: a push service's latency must not slow the job.
|
||||
*/
|
||||
void notifyBotMessage({ message });
|
||||
if (!quiet_push) void notifyBotMessage({ message });
|
||||
}
|
||||
|
||||
if (relay) {
|
||||
@@ -58,7 +64,7 @@ async function push({ bot_id, message, bot, relay }: Params) {
|
||||
data: { event: "server:bot-message", data: relay.message },
|
||||
});
|
||||
|
||||
void notifyBotMessage({ message: relay.message });
|
||||
if (!quiet_push) void notifyBotMessage({ message: relay.message });
|
||||
}
|
||||
|
||||
if (bot) {
|
||||
|
||||
Reference in New Issue
Block a user