Make routines act on their own and check every bot action

Scheduled runs are pre-approved: they carry out their brief instead of
drafting for confirmation. A run that can't do its brief calls
report_run_failed and is retried 5 and 10 minutes later; only then does
the user get one "failed after N tries" message, and the routine keeps
why in last_error.

Every reply and task now gets ActionCheckNote: look for an existing
record before creating one, and verify an action took effect before
saying it's done. Memory and instructions saves are read back, and
create_bot, create_schedule, start_task and delegate_task refuse
duplicates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-20 08:45:49 +01:00
co-authored by Claude Opus 5.5
parent 6c57fae444
commit 535adb6e16
25 changed files with 448 additions and 16 deletions
@@ -24,8 +24,8 @@ type Params = {
/**
* Records a failed attempt. The job goes back to the queue with a backoff
* delay, or, once out of attempts (or `fatal`), is marked failed and the
* bot tells the user it needs them. Returns the job's new status and the
* failure notice, if one was sent.
* bot tells the user it needs them. Returns the job's new status, the
* attempts it has used and the failure notice, if one was sent.
*/
export default async function failBotJob({
job_id,
@@ -58,9 +58,10 @@ export default async function failBotJob({
}
const status = res.singleRes.status;
const attempts = Number(res.singleRes.attempts || 0);
if (status != "failed") {
return { status, notice: null };
return { status, attempts, notice: null };
}
const notice = await notifyBotJobFailed({
@@ -68,5 +69,5 @@ export default async function failBotJob({
failure_message,
});
return { status, notice };
return { status, attempts, notice };
}
@@ -0,0 +1,24 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
session_id: number | string;
title: string;
};
/**
* The bot's task in this session with this title that's still queued or
* working, if any, so the same work isn't started twice.
*/
export default async function grabOpenBotTask({ bot_id, session_id, title }: Params) {
const res = await BunSQLite.sql<{ id: number; status: string }>({
sql: `SELECT id, status FROM bots_queue
WHERE bot_id = ? AND session_id = ? AND kind = 'task'
AND status IN ('pending', 'processing')
AND lower(trim(title)) = lower(trim(?))
ORDER BY id DESC LIMIT 1`,
values: [bot_id, session_id, title],
});
return res.payload?.[0] || null;
}
@@ -0,0 +1,47 @@
import BunSQLite from "@moduletrace/bun-sqlite";
export type BotSavedTextKind =
| "core_memory"
| "core_instructions"
| "user_memory"
| "workspace_memory"
| "workspace_instructions";
type Params = {
kind: BotSavedTextKind;
bot_id: number | string;
/**
* Whose memory, for `user_memory`.
*/
user_id?: number | string;
/**
* Which workspace, for the workspace kinds.
*/
workspace_id?: number | string | null;
};
/**
* Reads back one of a bot's memories or instructions as stored, so a save
* can be checked. Empty when there's none. The caller checks access.
*/
export default async function grabBotSavedText({ kind, bot_id, user_id, workspace_id }: Params) {
const query =
kind == "core_memory"
? { sql: `SELECT memory AS text FROM bots WHERE id = ?`, values: [bot_id] }
: kind == "core_instructions"
? { sql: `SELECT system_prompt AS text FROM bots WHERE id = ?`, values: [bot_id] }
: kind == "user_memory"
? {
sql: `SELECT memory AS text FROM bot_user_memories WHERE bot_id = ? AND user_id = ?`,
values: [bot_id, user_id ?? 0],
}
: {
sql: `SELECT ${kind == "workspace_memory" ? "memory" : "system_prompt"} AS text
FROM workspace_bots WHERE bot_id = ? AND workspace_id = ?`,
values: [bot_id, workspace_id ?? 0],
};
const res = await BunSQLite.sql<{ text: string | null }>({ ...query, sql: `${query.sql} LIMIT 1` });
return res.payload?.[0]?.text || "";
}
@@ -0,0 +1,21 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
cron_job_id: number;
/**
* The run's job: only the schedule's latest run records its failure.
*/
job_id: number;
error: string;
};
/**
* Keeps why a schedule's latest run failed in `last_error`, so Routines
* shows it. The next run that's queued clears it.
*/
export default async function recordCronJobFailure({ cron_job_id, job_id, error }: Params) {
await BunSQLite.sql({
sql: `UPDATE cron_jobs SET last_error = ?, updated_at = ? WHERE id = ? AND last_job_id = ?`,
values: [`Last run failed: ${error}`, Date.now(), cron_job_id, job_id],
});
}