Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# The WebUI runs its own scheduled tasks until the runtime offers one

The WebUI ships a scheduled-task surface backed by its own store and its own
in-process tick loop. It does not wait for the runtime's cron capability, which
is not reachable from a loopback WebUI host, and it does not read the runtime's
cron tables. This is a **temporary** arrangement with a written retirement
condition below, not a second permanent product.

## Why the runtime's cron is not reachable from here

Measured on `webui` at `2f064db` (2026-10-05), against a real WebUI host:

- **The v1 path is off and its data is gone.** `local-runtime-v2/src/compat/v1/runtime.ts`
pins `cronConsumerEnabled: false` for every v2-compat host, and
`local-runtime/src/cron/api.ts` returns early from `ensureStarted` in that
case, so its registry is never filled. The table it reads,
`local_runtime_crons`, holds **0 rows**; the migration
`local-runtime-v2/src/infra/db/migrations/cron/migration-0002-copy-legacy-cron-data.ts`
moved the data out.
- **The v2 path is not created for this host.** `enableCron` comes from
`ownsElectronRuntimeCapabilities(runtimeOwnerKind)`
(`local-runtime-v2/src/application/agent/runtime-browser-use-composition.ts`),
and the WebUI declares `runtimeOwnerKind: "tui"`
(`packages/webui/src/server/assembly.ts`), so no `CronService` is built.
- **The service would not be visible even if it were built.**
`CreatedLocalRuntimeHost` carries only `application?` and `cliService?`
(`local-runtime-v2/src/local/host-contract.ts`). The one place the services
object is spread whole is the `cliService`'s own `options`
(`local-runtime-v2/src/runtime.ts`), and there the `cron` slot is
`undefined` for the same reason.

An earlier attempt to reach it by declaring the WebUI an Electron owner made
things worse and was reverted: `isV2RuntimeOwner` requires
`capabilities.electronHost` for that kind, and without it the whole v2 service
group — including the `cliService` every other WebUI feature depends on — goes
away. A closed loop over the reachable object graph (depth 5) found no
`CronService` instance anywhere the WebUI can already hold.

## What the WebUI owns instead

- Its own file, `<dataDir>/webui/scheduled-tasks.sqlite`, and its own table.
It never selects from `local_runtime_crons` or
`local_runtime_v2_cron_definitions`. Reading the latter from here would be the
worst available combination: a second scheduler with none of v2's
concurrency guards, writing turns into the same agent queue as the desktop
client.
- An in-process tick loop in `WebuiService`, beside the existing heartbeat. The
runtime itself has no timer, so a runtime not attached to a service never
fires.
- A port, `WebuiScheduledTaskPort`, whose six methods — `listScheduledTasks`,
`createScheduledTask`, `updateScheduledTask`, `deleteScheduledTask`,
`triggerScheduledTaskNow`, `getScheduledTaskCapability` — name the
operations and not the engine. That is what makes the retirement below a
one-adapter change.

## Considered Options

- **Use the v1 cron path** — rejected: the switch is pinned off, and its table
is empty, so it would display nothing and could not be made to.
- **Read v2's tables directly** — rejected for the reason above: it is the one
combination with two executors and no claim. See
`local-runtime-v2/src/service/cron/adapters/run.repository.ts`, where
`claimExecution` is an atomic conditional update and
`insertPendingScheduled` converges on a trigger id. All of that protection
lives in the repository, and bypassing it is the whole cost.
- **Open the runtime's gate and wait** — the runtime's own comment marks cron
as an Electron-only capability absent on embedded hosts, so this is a
deliberate design position, not an oversight. The WebUI is a loopback process
the user starts; asking it to become the execution role is an upstream
architecture decision, and it was raised with the upstream maintainer rather
than decided here.
- **Keep the panel and show an empty list** — rejected: an empty list reads as
"you have no scheduled tasks", which is a statement about the user when it is
actually a statement about the host.

## Retirement condition

**Retire this as soon as the runtime hands a WebUI host a cron capability the
host can actually reach.** Concretely, both of these become true:

1. `services.cron` is created for a `tui` + `cliEmbedded` host — today
`enableCron` is gated on `runtimeOwnerKind === "electron" | undefined`; and
2. the created service is reachable from the host, i.e. carried on
`CreatedLocalRuntimeHost` (or on the `cliService` options) rather than
sealed inside `createLocalRuntimeHostV2`'s locals.

The retirement itself:

1. Implement `WebuiScheduledTaskPort` against the runtime's `CronService` and
delete the in-process store, the tick loop and the SQLite dependency. Only
the adapter changes; the port, the operations and the panel do not.
2. Migrate `webui_scheduled_task` into the runtime's table. Tasks created before
the switch are otherwise stranded, so the migration is part of the change and
not a follow-up.
3. **Never run both.** The own loop must be torn down in the same change that
arms the runtime's scheduler. Two schedulers over one queue is precisely the
failure this decision exists to avoid.

While the runtime keeps the capability closed, this decision stands as written.
It is not a claim that the WebUI is the right owner of scheduled execution; it
is that a panel which cannot see a real task list is worse than one that owns a
small, honest one.

## Consequences

- **A scheduled task in the WebUI and a scheduled task in the desktop client
are two different things that cannot see each other.** This is the price, and
it is paid knowingly.
- **The WebUI process being alive is the execution guarantee.** The package is
started on demand (`mcode-webui`, loopback port 8787); when nobody is running
it, nothing fires. Slots missed while it was down are **not** replayed — they
are counted (`missed_count`, `last_missed_at_ms`) so the surface can say so
rather than imply the task never existed.
- **The scope is fixed and the scheduler is dumb.** Once and fixed interval,
no cron expressions, no timezone and no active hours. A cron expression is a
new capability, not a gap in this one.
- **The WebUI takes a first dependency it did not have.** It persists now:
`better-sqlite3` is declared in `packages/webui/package.json` for this reason.
1 change: 1 addition & 0 deletions packages/webui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@mavis/shared": "workspace:^",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"better-sqlite3": "12.11.1",
"highlight.js": "10.7.3",
"katex": "0.18.7",
"lottie-web": "^5.13.0",
Expand Down
57 changes: 57 additions & 0 deletions packages/webui/src/server/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
extractWorkspaceArchiveDirectory,
readWorkspaceArchiveListing,
} from "./workspace-archive.js";
import type { WebuiScheduledTaskRuntime } from "./scheduled-task-scheduler.js";
import type {
WebuiHarnessPort,
WebuiSessionListRequest,
Expand Down Expand Up @@ -339,6 +340,14 @@ export interface WebuiRuntimeHostHandle {
* the runtime host; the WebUI only needs the structural shape to forward.
*/
readonly cliService?: WebuiRuntimeCliService;
/**
* Scheduled tasks, when the host carries a runtime for them. Optional on
* purpose: a host that predates this surface still type-checks, and every
* scheduled-task method below fails closed with one clear message instead of
* crashing on an undefined call. The WebUI's own service supplies its local
* runtime rather than routing through here — see `service.ts`.
*/
readonly scheduledTasks?: WebuiScheduledTaskRuntime;
}

/**
Expand Down Expand Up @@ -444,6 +453,38 @@ export function createHarnessPortFromHost(
async clearGoal(request) {
return { success: await requireCliService(host).clearGoal(request.sessionId) };
},
// Scheduled tasks. The capability probe answers instead of throwing, so a
// client can ask "can this host do scheduled tasks at all?" and render the
// reason; the other five are operations, and an operation that cannot be
// served reports the same reason as a `harness_error`.
async listScheduledTasks(request) {
return requireScheduledTasks(host).listScheduledTasks(request);
},
async createScheduledTask(request) {
return requireScheduledTasks(host).createScheduledTask(request);
},
async updateScheduledTask(request) {
return requireScheduledTasks(host).updateScheduledTask(request);
},
async deleteScheduledTask(request) {
return requireScheduledTasks(host).deleteScheduledTask(request);
},
async triggerScheduledTaskNow(request) {
return requireScheduledTasks(host).triggerScheduledTaskNow(request);
},
async getScheduledTaskCapability() {
return host.scheduledTasks
? host.scheduledTasks.getScheduledTaskCapability()
: {
available: false,
// No implementation answered, which is a different fact from "our
// own implementation is unavailable". See
// `WebuiScheduledTaskCapabilitySource`.
source: "none" as const,
reason:
"scheduled tasks are not available: this host exposes no scheduled-task runtime",
};
},
async listWorkspaceFileTree(request) {
const tree = await requireCliService(host).listWorkspaceFileTree!(request) as readonly WebuiWorkspaceFile[];
// The runtime reports names and shape but no file facts, while the port
Expand Down Expand Up @@ -754,6 +795,22 @@ export function createHarnessPortFromHost(
* helper so the failure message is the same as it was before the batch-C
* seam work.
*/
/**
* Resolve the host's scheduled-task runtime, or fail closed. The message is
* the one the capability probe reports, so a client that asked first and a
* client that called blind see the same reason.
*/
function requireScheduledTasks(
host: WebuiRuntimeHostHandle,
): WebuiScheduledTaskRuntime {
const runtime = host.scheduledTasks;
if (!runtime)
throw new Error(
"scheduled tasks are not available: this host exposes no scheduled-task runtime",
);
return runtime;
}

function requireCliService(host: WebuiRuntimeHostHandle): WebuiRuntimeCliService {
if (!host.cliService)
throw new Error("runtime host does not expose the CLI service");
Expand Down
6 changes: 6 additions & 0 deletions packages/webui/src/server/operation/names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,9 @@ export const REVEAL_MODEL_PROVIDER_API_KEY_OPERATION_NAME = "revealModelProvider
export const START_CODEX_OAUTH_LOGIN_OPERATION_NAME = "startCodexOAuthLogin" as const;
export const CANCEL_CODEX_OAUTH_LOGIN_OPERATION_NAME = "cancelCodexOAuthLogin" as const;
export const REFRESH_MODELS_OPERATION_NAME = "refreshModels" as const;
export const LIST_SCHEDULED_TASKS_OPERATION_NAME = "listScheduledTasks" as const;
export const CREATE_SCHEDULED_TASK_OPERATION_NAME = "createScheduledTask" as const;
export const UPDATE_SCHEDULED_TASK_OPERATION_NAME = "updateScheduledTask" as const;
export const DELETE_SCHEDULED_TASK_OPERATION_NAME = "deleteScheduledTask" as const;
export const TRIGGER_SCHEDULED_TASK_OPERATION_NAME = "triggerScheduledTaskNow" as const;
export const GET_SCHEDULED_TASK_CAPABILITY_OPERATION_NAME = "getScheduledTaskCapability" as const;
16 changes: 16 additions & 0 deletions packages/webui/src/server/operation/operation-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ export type WebuiOperationPort = Pick<
| "refreshModels"
| "requestCompaction"
| "invalidateAuth"
| "listScheduledTasks"
| "createScheduledTask"
| "updateScheduledTask"
| "deleteScheduledTask"
| "triggerScheduledTaskNow"
| "getScheduledTaskCapability"
>;

export type WebuiOperationHandlers = {
Expand Down Expand Up @@ -278,6 +284,16 @@ export function createOperationHandlers(
createGoal: async (_context, body) => ({ body: await port.createGoal(body) }),
patchGoal: async (_context, body) => ({ body: await port.patchGoal(body) }),
clearGoal: async (_context, body) => ({ body: await port.clearGoal(body) }),
// Scheduled tasks are WebUI-owned, but they reach the client through the
// same registry as everything else, so the panel needs no transport of its
// own. The port methods are the seam; `WebuiService` supplies its own local
// runtime behind them.
listScheduledTasks: async (_context, body) => ({ body: await port.listScheduledTasks(body) }),
createScheduledTask: async (_context, body) => ({ body: await port.createScheduledTask(body) }),
updateScheduledTask: async (_context, body) => ({ body: await port.updateScheduledTask(body) }),
deleteScheduledTask: async (_context, body) => ({ body: await port.deleteScheduledTask(body) }),
triggerScheduledTaskNow: async (_context, body) => ({ body: await port.triggerScheduledTaskNow(body) }),
getScheduledTaskCapability: async () => ({ body: await port.getScheduledTaskCapability() }),
listSessions: async (_context, body) => ({ body: await port.listSessions(body) }),
listVisibleProjects: async (_context, body) => {
if (!port.listVisibleProjects) throw new Error("runtime host does not expose project listing");
Expand Down
13 changes: 13 additions & 0 deletions packages/webui/src/server/operation/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { watchEventsOperation, listPendingPermissionsOperation, getPendingQuesti
import { abortSessionOperation, listQueueMessagesOperation, deleteQueueItemOperation, listModelsOperation, listSkillsOperation, selectModelOperation, getSessionUsageOperation, getUsageQuotaOperation, getAccountStatusOperation } from "./queue.js";
import { pluginManagementOperation } from "./plugin-management.js";
import { getPermissionModeOperation, setPermissionModeOperation } from "./permission-mode.js";
import { listScheduledTasksOperation, createScheduledTaskOperation, updateScheduledTaskOperation, deleteScheduledTaskOperation, triggerScheduledTaskNowOperation, getScheduledTaskCapabilityOperation } from "./scheduled-task.js";
import { archiveSessionOperation, deleteSessionOperation, updateSessionOperation, getSessionForkOptionsOperation, forkSessionOperation, listUserModelProvidersOperation, createUserModelProviderOperation, updateUserModelProviderOperation, deleteUserModelProviderOperation, testUserModelProviderOperation, testUserModelOperation, discoverUserModelsCandidateOperation, saveUserModelProviderCandidateOperation, listProviderPresetsOperation, getMiniMaxApiKeyStatusOperation, upsertMiniMaxApiKeyOperation, getCodexOAuthStatusOperation, getMiniMaxModelSourceOperation, setMiniMaxModelSourceOperation, testUserModelCandidateOperation, revealModelProviderApiKeyOperation, startCodexOAuthLoginOperation, cancelCodexOAuthLoginOperation, refreshModelsOperation, runCommandOperation, getSigninPanelOperation, claimSigninOperation, signOutOperation } from "./provider.js";
export { versionOperation, listSessionsOperation, listVisibleProjectsOperation, getSessionTreeOperation, createSessionOperation, getSessionOperation, getActiveTurnOperation } from "./session.js";
export { listWorkspaceFileTreeOperation, browseWorkspaceDirsOperation, readWorkspaceFileOperation, getWorkspaceEnvironmentOperation, mutateWorkspaceGitOperation, getWorkspaceReviewSummaryOperation, listWorkspaceReviewFileDiffsOperation, getWorkspaceReviewFileContentOperation, searchWorkspaceReviewDiffsOperation, readCanvasOperation, applyCanvasOperation, readWorkspaceArchiveOperation, extractWorkspaceArchiveOperation, createTerminalOperation, listTerminalsOperation, writeTerminalOperation, resizeTerminalOperation, disposeTerminalOperation, watchTerminalOperation } from "./workspace.js";
Expand All @@ -17,6 +18,7 @@ export { watchEventsOperation, listPendingPermissionsOperation, getPendingQuesti
export { abortSessionOperation, listQueueMessagesOperation, deleteQueueItemOperation, listModelsOperation, listSkillsOperation, selectModelOperation, getSessionUsageOperation, getUsageQuotaOperation, getAccountStatusOperation } from "./queue.js";
export { pluginManagementOperation } from "./plugin-management.js";
export { getPermissionModeOperation, setPermissionModeOperation } from "./permission-mode.js";
export { listScheduledTasksOperation, createScheduledTaskOperation, updateScheduledTaskOperation, deleteScheduledTaskOperation, triggerScheduledTaskNowOperation, getScheduledTaskCapabilityOperation } from "./scheduled-task.js";
export { archiveSessionOperation, deleteSessionOperation, updateSessionOperation, getSessionForkOptionsOperation, forkSessionOperation, listUserModelProvidersOperation, createUserModelProviderOperation, updateUserModelProviderOperation, deleteUserModelProviderOperation, testUserModelProviderOperation, testUserModelOperation, discoverUserModelsCandidateOperation, saveUserModelProviderCandidateOperation, listProviderPresetsOperation, getMiniMaxApiKeyStatusOperation, upsertMiniMaxApiKeyOperation, getCodexOAuthStatusOperation, getMiniMaxModelSourceOperation, setMiniMaxModelSourceOperation, testUserModelCandidateOperation, revealModelProviderApiKeyOperation, startCodexOAuthLoginOperation, cancelCodexOAuthLoginOperation, refreshModelsOperation, runCommandOperation, getSigninPanelOperation, claimSigninOperation, signOutOperation } from "./provider.js";
import { createOperationHandlers, type WebuiOperationPort } from "./operation-handlers.js";
import type {
Expand Down Expand Up @@ -109,6 +111,17 @@ export function createOperationRegistry(
registerOperation(registry, { operation: selectModelOperation, handle: handlers.selectModel });
registerOperation(registry, { operation: listSkillsOperation, handle: handlers.listSkills });
registerOperation(registry, { operation: pluginManagementOperation, handle: handlers.pluginManagement });
// The WebUI's own scheduled tasks. Registered unconditionally for the same
// reason the other eighteen are: the port type requires the methods, so the
// registry contents must not depend on which host fills them. A host that
// cannot serve them throws inside the handler and the dispatcher reports it
// as `harness_error` with the host's own message.
registerOperation(registry, { operation: listScheduledTasksOperation, handle: handlers.listScheduledTasks });
registerOperation(registry, { operation: createScheduledTaskOperation, handle: handlers.createScheduledTask });
registerOperation(registry, { operation: updateScheduledTaskOperation, handle: handlers.updateScheduledTask });
registerOperation(registry, { operation: deleteScheduledTaskOperation, handle: handlers.deleteScheduledTask });
registerOperation(registry, { operation: triggerScheduledTaskNowOperation, handle: handlers.triggerScheduledTaskNow });
registerOperation(registry, { operation: getScheduledTaskCapabilityOperation, handle: handlers.getScheduledTaskCapability });
registerOperation(registry, { operation: getPermissionModeOperation, handle: handlers.getPermissionMode });
registerOperation(registry, { operation: setPermissionModeOperation, handle: handlers.setPermissionMode });
registerOperation(registry, { operation: getSessionUsageOperation, handle: handlers.getSessionUsage });
Expand Down
Loading
Loading