Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### The Routines page says when nothing is there to run them

A routine needs a second process to fire it, and a deployment that never started one looked exactly
like a deployment that had: the routine was stored, its schedule was computed, and the page showed it
waiting with a next run time, right up until nobody's standup notes arrived. Every sweep now records
that it happened, and the page reads that record. Somebody with standing routines and nothing
sweeping is told so — that no worker has ever checked in, or when the last one did — instead of
being shown a page that looks correct. The window is the fifteen minutes a routine's own schedule
already has as its floor, so a gap longer than that is one no routine could have wanted.

### A deployment directory pasted with a stray space goes where it says

The desktop setup screen asks where OpenBot should live, enables Start once that box is not blank
Expand Down
24 changes: 23 additions & 1 deletion app/src/components/routines/routines-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
setRoutineEnabledMutationOptions,
} from "@/lib/routines/mutations";
import {
nothingIsFiring,
type RoutineRecord,
routinesQueryOptions,
} from "@/lib/routines/queries";
Expand Down Expand Up @@ -143,9 +144,11 @@ export function RoutinesList({
const deleteRoutine = useMutation(deleteRoutineMutationOptions(queryClient));
/** The routine a delete is being confirmed for, or null. Its own dialog rather than one per row. */
const [confirmingId, setConfirmingId] = useState<string | null>(null);
const rows = (routines.data ?? []).filter(
const rows = (routines.data?.routines ?? []).filter(
(row) => agentId === undefined || row.agentId === agentId,
);
const sweep = routines.data?.sweep;
const noWorker = nothingIsFiring(sweep, rows.length);
const confirming = rows.find((row) => row.id === confirmingId) ?? null;

return (
Expand All @@ -156,6 +159,25 @@ export function RoutinesList({
</p>
) : null}

{noWorker ? (
<Item
variant="muted"
className="mb-2 border-amber-500/40 bg-amber-500/5"
role="alert"
>
<ItemContent>
<ItemTitle className="text-amber-600 dark:text-amber-500">
Nothing is running these
</ItemTitle>
<ItemDescription>
{sweep?.lastSweptAt
? `The routines worker last checked ${relativeTime(sweep.lastSweptAt)}. Until it is running again, none of these will fire.`
: "No routines worker has ever checked in, so none of these will fire. A deployment needs one running to carry them out."}
</ItemDescription>
</ItemContent>
</Item>
) : null}

{/* Pending renders nothing: the empty-state sentence would otherwise flash for the fetch. */}
{routines.isPending ? null : routines.error ? (
<p className="mt-4 text-destructive text-sm" role="alert">
Expand Down
29 changes: 26 additions & 3 deletions app/src/lib/routines/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ export type RoutineRecord = {
} | null;
};

export type SweepRecord = {
lastSweptAt: string | null;
working: boolean;
};

export type RoutinesPage = {
routines: RoutineRecord[];
sweep: SweepRecord;
};

export function nothingIsFiring(
sweep: SweepRecord | undefined,
routineCount: number,
): boolean {
return sweep !== undefined && !sweep.working && routineCount > 0;
}

export const routineKeys = {
all: ["routines"] as const,
list: () => ["routines", "list"] as const,
Expand All @@ -45,9 +62,15 @@ export const routineKeys = {
export function routinesQueryOptions() {
return queryOptions({
queryKey: routineKeys.list(),
queryFn: (): Promise<RoutineRecord[]> =>
client("/api/routines", "routines", {
queryFn: async (): Promise<RoutinesPage> => {
const response = await client("/api/routines", {
fallback: "Your routines could not be loaded.",
}),
});
const body = (await response.json()) as Partial<RoutinesPage>;
return {
routines: body.routines ?? [],
sweep: body.sweep ?? { lastSweptAt: null, working: false },
};
},
});
}
34 changes: 34 additions & 0 deletions app/tests/routines-no-worker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test";
import { nothingIsFiring, type SweepRecord } from "@/lib/routines/queries";

const sweeping: SweepRecord = {
lastSweptAt: new Date().toISOString(),
working: true,
};
const quiet: SweepRecord = {
lastSweptAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
working: false,
};
const never: SweepRecord = { lastSweptAt: null, working: false };

describe("when the routines page should say nothing is running them", () => {
test("says so when a worker has gone quiet and routines are standing", () => {
expect(nothingIsFiring(quiet, 2)).toBe(true);
});

test("says so when no worker has ever checked in", () => {
expect(nothingIsFiring(never, 1)).toBe(true);
});

test("stays quiet while a worker is sweeping", () => {
expect(nothingIsFiring(sweeping, 2)).toBe(false);
});

test("stays quiet when there is nothing scheduled to miss", () => {
expect(nothingIsFiring(quiet, 0)).toBe(false);
});

test("stays quiet before the page knows, so the warning cannot flash on load", () => {
expect(nothingIsFiring(undefined, 2)).toBe(false);
});
});
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ Bot containers on a run that mints one.
**`WORKER_SHARED_SECRET`** is the same shape of secret for a different pair: it is what the routines
worker presents to `/internal/routines/run` to prove a routine's dispatch actually came from it. The
API server refuses a handoff without one configured, and the worker refuses to start without one at
all. See [routines.md](routines.md) for what a deployment with no worker at all looks like — it is
not obvious from the screen.
all. See [routines.md](routines.md) for what a deployment with no worker at all looks like — the
Routines page says so when nothing has swept.

Unlike `AGENT_TOOL_TOKEN`, `start.sh` does not generate and persist this one. It supplies a fixed
local default, `openbot-dev-worker-secret`, the same value every clone of this repository gets. That
Expand Down
14 changes: 10 additions & 4 deletions docs/routines.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,16 @@ Nothing above happens without a second process. The API server answers `/interna
it is handed a run, but nothing hands it one on its own — that is a separate worker's whole job, and a
deployment that never started one schedules nothing.

This fails silently. A routine created in chat is stored, its schedule is computed, and the Routines
page shows it sitting there with a next run time like any other — because as far as that page knows,
it is correct. Nothing on the screen says a worker exists to act on it, so a deployment with no worker
looks identical to one running normally, right up until nobody's standup notes ever arrive.
This used to fail silently. A routine created in chat is stored, its schedule is computed, and the
Routines page shows it sitting there with a next run time like any other — because as far as that
page knows, it is correct. A deployment with no worker looked identical to one running normally,
right up until nobody's standup notes ever arrive.

Each sweep now records that it happened, in `routine_sweeps`, and the Routines page reads it. A
person with standing routines and nothing sweeping is told so: that no worker has ever checked in,
or when the last one did. The window is `MINIMUM_INTERVAL_MS` — fifteen minutes, the floor a
routine's own schedule already has, so a gap longer than that is one no routine could have wanted.
A CronJob scheduled less often than that will read as quiet between runs.

Two settings carry this:

Expand Down
5 changes: 5 additions & 0 deletions server/drizzle/0029_routine_sweeps.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE TABLE "routine_sweeps" (
"id" text PRIMARY KEY NOT NULL,
"swept_at" timestamp with time zone DEFAULT now() NOT NULL,
"owner" text
);
Loading