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.
124 changes: 74 additions & 50 deletions docs/webui/pending-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,69 +63,93 @@ different chip.

---

## 2. The thinking switch does not persist
## 2. Does a committed thinking choice survive a reload?

**Status:** open. Blocked on a runtime contract this repository does not have.
**Status:** open, and the symptom behind it is now **unverified**. It is
recorded here because the control's contract gap is real; the behaviour it used
to cause has not been re-measured since the escape hatch landed.

### Symptom
### What was measured, and when

On a two-state model (M3), the brain icon is a real button and accepts clicks.
Committing a click does not stick: the icon returns to its unstated state.
The symptom this section originally described was: on a two-state model (M3) the
brain icon accepts a click and then returns to its unstated state — the control
was operable and the result discarded. That was a live observation at the time.

**It has not been reproduced since.** A browser run after this branch merged
confirmed the toggle now emits `variant: "thinking"` when switched on and
`variant: ""` when switched off (`variantForEffort`, `ModelPicker.tsx:302`).
Nobody has checked whether either survives a page reload, which is what the
original symptom was actually about. Until someone does, treat the old symptom
as history, not as the current state.

This is NOT the same as the disabled-button bug fixed in `a440ad5`. That fix
made the control operable; this is the control being operable and the runtime
discarding the result.
made the control operable at all.

### Root cause
### The contract gap that remains real

`resolveEffortOptions` (`ModelPicker.tsx`) decides a model is a two-state
switch from `thinkingConfig.mode === "switchable"` PLUS a
`supportedVariants` array containing both an empty and a non-empty entry:
`resolveEffortOptions` (`ModelPicker.tsx:252`) infers that a model is a
two-state switch from `thinkingConfig.mode === "switchable"` PLUS a
`supportedVariants` array holding both an empty and a non-empty entry:

```ts
if (model.thinkingConfig?.mode === "switchable") {
const variants = model.supportedVariants ?? []; // always undefined
const hasOff = variants.includes("");
const hasOn = variants.some((variant) => Boolean(variant));
if (hasOff && hasOn) return ["off", "on"];
const variants = model.supportedVariants ?? [];
const hasOff = variants.includes("") || variants.includes("none-thinking");
const hasOn = variants.some((variant) => variant && variant !== "none-thinking");
const declaredSwitch = model.thinkingConfig?.default_value !== undefined;
if ((hasOff && hasOn) || declaredSwitch) return ["off", "on"];
}
```

`supportedVariants` is declared on the client contract
(`client/contracts.ts:102`) but **not** on the server's `WebuiModelEntry`
(`server/port.ts:739-758`). The server never sends it, so the branch never
runs, and the option list falls through to whatever `effortOptions` carries.

The model's own configuration is correct and complete — `~/.minimax/config.yaml`
gives M3 both `variants: { none-thinking, thinking }` and
`thinking_config: { mode: switchable }`. The data exists; it stops at the
webui port.

A second mismatch sits behind it: `variantForEffort` returns `""` for "off",
while the configured variant is named `none-thinking`. Even with the field
wired through, turning thinking OFF would send a name the model does not have.

### Why it is not fixed here

The fix is a data-contract change, not a UI change: add `supportedVariants` to
`WebuiModelEntry`, populate it from the model's configured `variants`, and
teach `variantForEffort` the `none-thinking` spelling. That crosses into
`local-runtime-v2`'s model system, which is outside the scope of "port the H
task's UI".

It is also pre-existing, not introduced by this branch: the dependency on
`supportedVariants` dates to `0536fce`, and before `a440ad5` the button was
disabled outright, so the same pick never persisted. This branch made the
failure visible by making the control work.
(`client/contracts.ts:140`) but **still not** on the server's `WebuiModelEntry`
— it appears nowhere in `server/port.ts`, so the server never sends it and the
`hasOff && hasOn` inference never runs.

What changed is the second half of that condition. `declaredSwitch` reads
`thinkingConfig.default_value`, and `thinkingConfig` **is** on the server
contract (`server/port.ts:870`). So the switch now surfaces through the
runtime's own statement that the model has one, and no longer depends on a
second field arriving intact. That is why the symptom above is unverified
rather than confirmed: the path that used to fail is no longer the one being
taken.

The model's configuration is complete — `~/.minimax/config.yaml` gives M3 both
`variants: { none-thinking, thinking }` and `thinking_config: { mode:
switchable }`.

### Correction: there is no `""` / `none-thinking` mismatch

An earlier revision of this section claimed a second, deeper problem:
`variantForEffort` returns `""` for "off" while the configured variant is named
`none-thinking`, so switching thinking off "would send a name the model does not
have".

**That is wrong, and acting on it would break working code.** The two spellings
are not compared at the same layer. The runtime normalises the catalogue before
it ever reaches the client
(`local-runtime/src/model-provider/list-models.ts:136`):

### What a decision changes

- **Fix now** — the switch becomes a switch. Two files in `local-runtime-v2`,
one field in `server/port.ts`, one branch in `ModelPicker.tsx`.
- **Fix separately** — this branch merges with the icon and menu work; the
contract gap gets its own change with its own review.

### How to decide
```ts
.map(([variant]) => (variant === 'none-thinking' ? '' : variant));
```

Nothing about the UI argues either way. The only question is whether a runtime
contract change should ride along with a UI port or land on its own.
So a client reading `supportedVariants` sees `["", "thinking"]` — `none-thinking`
does not exist on the wire. Sending `""` is the correct spelling, and the
`variants.includes("none-thinking")` clause in the read side is there for
catalogues that never passed through that normalisation.

### What is actually open

1. **Does the choice survive a reload?** One browser test settles it. Nothing
about it argues for or against — it is a measurement nobody has taken.
2. **Should `supportedVariants` be wired through anyway?** It would remove the
reliance on `default_value` as an escape hatch and let the client infer the
switch from the catalogue. That is a data-contract change: one field on
`WebuiModelEntry` plus a population site in the runtime's model system, which
is outside "port the H task's UI".

Both predate this branch. The dependency on `supportedVariants` dates to
`0536fce`, and before `a440ad5` the button was disabled outright, so the same
pick never persisted either. This branch made the control work, which is what
turned the question from "is the button live" into "does the answer stick".
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
Loading
Loading