Skip to content
Closed
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
Expand Up @@ -24,3 +24,54 @@ session is an explicit `resumeSession` with a cursor, not an automatic continuat
Execution state that is not persisted — stream buffers, subscriptions, pending
permission and questionnaire requests — belongs to the owner process and cannot be
recovered from disk.

## Amendment: the resident service owns the scheduled-task schedule

The WebUI is a resident local service, not a surface that runs on demand. The
published `mcode-webui` bin assembles the host, prints a URL, and blocks until
SIGINT or SIGTERM; the browser page is a client that attaches to it. The
scheduled-task panel is therefore a control surface over a store the service owns,
and the service runs the schedule for as long as it is up.

**Unchanged.** `startupExecutionPolicy` stays `'quarantined'` and
`runtimeOwnerKind` stays `'tui'`. Neither is widened; the new capability arrives as
a separate host option rather than as a reclassification of the owner.

**What the option does.** The host takes `enableScheduledTasks`. When set, the
process composes the in-process scheduler and the cron service, and the scheduler
starts with `restorePersistedJobExecution: true`, so a restart re-arms the timers
for definitions already in the store. The timers are in-process: nothing is handed
to an operating-system scheduler, because these tasks are harness business and
have to stay governable from inside it. With the process down, a schedule that
comes due simply does not fire.

Re-arming at startup is the difference between a resident service and a page that
happens to run something. A schedule created from the desktop or the CLI has to
keep firing across a WebUI restart; otherwise every restart silently retires every
task, with nothing in the logs to say so.

**The split, stated precisely.** Two mechanisms get conflated while this is being
designed, and the amendment is clearer for separating them:

- *Restoring the schedule* — re-arming timers for stored definitions. Enabled here,
per capability.
- *Recovering in-flight work* — picking up runs that fired but did not finish.
Governed by `recoverPersistedRuns`, and **not** changed by this amendment.

**Sessions and turns are untouched.** A restart still marks a running turn
`interrupted`; reopening a session is still an explicit `resumeSession` with a
cursor. Restoring a schedule is not resuming anybody's unfinished work.

**Re-opened on purpose.** The second rejected option above turned on "a WebUI
restart and the terminal client would both attempt to resume the same persisted
jobs". That hazard is now accepted for scheduled tasks specifically: the data
directory is shared, so the WebUI service and the terminal client can both hold the
scheduler for one agent and fire the same task twice. The busy-queue bounds overlap
within one process; it does not arbitrate across two. The panel states which side is
executing rather than implying the WebUI owns execution exclusively.

**The predicate is not shared.** The WebUI reaches the cron service through a
cron-specific ownership check, deliberately not by widening the existing
Electron-capability predicate: that predicate also gates channel delivery, and
widening it would hand a local web service the IM adapters it was never meant to
own.
8 changes: 7 additions & 1 deletion docs/webui/webui-v1-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ assembly it needs. The reasoning behind the decisions lives in [`../adr`](adr/).

## Out of scope for the first version

- Account, provider, plugin, cron and update panels. States that need them are
- Account, provider, plugin and update panels. States that need them are
reported as messages, not as configuration interfaces.
- Terminal rendering, terminal image preview, check-in
- Remote or LAN access — see
Expand All @@ -32,6 +32,12 @@ assembly it needs. The reasoning behind the decisions lives in [`../adr`](adr/).
- Automatic resume of persisted jobs at cold start — see
[ADR 0002](../adr/0002-in-process-runtime-host-with-quarantined-cold-start.md)

The scheduled-task (`定时`) panel is a later addition to this list. The WebUI is a
resident local service, so it owns the schedule for the life of the process and
re-arms its timers on start. ADR 0002 carries an amendment describing what that
does and does not change, including the two mechanisms it is easy to conflate:
restoring a schedule is not recovering in-flight work.

## Behaviour boundaries

- Shared history is readable, but live execution belongs to the runtime owner that
Expand Down
9 changes: 9 additions & 0 deletions packages/local-runtime-v2/src/local/host-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ interface CreateLocalRuntimeHostOptions extends V1CreateLocalRuntimeHostOptions
interface CreatedLocalRuntimeHost extends V1CreatedLocalRuntimeHost {
application?: LocalRuntimeApplication;
cliService?: import('./cli-service.js').CliService;
/**
* The scheduled-task capability, published on its own rather than as the whole
* `RuntimeServices` graph. A host that manages schedules needs exactly this one
* service; handing over `services` instead would also hand it `channelSystem`,
* `browserUse` and the session/turn owners, which is how a surface ends up
* owning capabilities it was never granted. Absent when this host owns no
* scheduler, so every consumer can fail closed on `undefined`.
*/
scheduledTasks?: import('../services.js').RuntimeServices['cron'];
}

export type {
Expand Down
34 changes: 32 additions & 2 deletions packages/local-runtime-v2/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
createBackgroundRuntime,
type BackgroundRuntime,
} from "./background-runtime.js";
import { resolveScheduledTaskScheduling } from "./service/cron/ownership.js";
import {
cleanupFailedV1Startup,
createDeferredAgentRuntimeTelemetry,
Expand Down Expand Up @@ -446,6 +447,11 @@ function createStartedHost(
...v1,
...(ownerRuntime ? { application: ownerRuntime.services.application } : {}),
...(cliService ? { cliService } : {}),
// Only the scheduled-task capability leaves this function, never the whole
// `services` graph -- see `CreatedLocalRuntimeHost.scheduledTasks`.
...(ownerRuntime?.services.cron
? { scheduledTasks: ownerRuntime.services.cron }
: {}),
apiHost: v1.apiHost,
ready,
...(ownerRuntime
Expand Down Expand Up @@ -812,6 +818,21 @@ function createBrowserUseServiceOptions(
};
}

/**
* v2 host option for scheduled tasks. `CreateLocalRuntimeHostOptions` lives in
* the host contract, so the optional field is read through a narrow widening
* here; hosts forward `enableScheduledTasks` at the factory boundary.
*/
function requestsScheduledTasks(options: CreateLocalRuntimeHostOptions): boolean {
return (
(
options as CreateLocalRuntimeHostOptions & {
readonly enableScheduledTasks?: boolean;
}
).enableScheduledTasks === true
);
}

async function initializeOwnerRuntime(input: {
readonly v1: V1CreatedLocalRuntimeHost;
readonly compatibility: V1RuntimeCompatibility;
Expand Down Expand Up @@ -841,14 +862,22 @@ async function initializeOwnerRuntime(input: {
options.startupExecutionPolicy,
);
const electronOwner = options.runtimeOwnerKind === "electron";
// Scheduled tasks stay process-local: an opted-in resident host owns the
// Scheduler and cron services without inheriting any Electron-only surface.
const scheduledTaskOwner = requestsScheduledTasks(options);
const scheduling = resolveScheduledTaskScheduling({
electronOwner,
startupExecutionEnabled,
enableScheduledTasks: scheduledTaskOwner,
});
background = await createBackgroundRuntime({
db: database.db,
dataDir: v1.dataDir,
logger,
metrics: v1.metricsClient,
...(options.nowMs ? { nowMs: options.nowMs } : {}),
restorePersistedJobExecution: startupExecutionEnabled,
enableScheduler: electronOwner,
restorePersistedJobExecution: scheduling.restorePersistedJobExecution,
enableScheduler: scheduling.schedulerOwned,
});
services = await createRuntimeServices({
db: database.db,
Expand All @@ -871,6 +900,7 @@ async function initializeOwnerRuntime(input: {
recoverPersistedState: startupExecutionEnabled,
greetingEnabled: electronOwner && startupExecutionEnabled,
runtimeOwnerKind: options.runtimeOwnerKind,
enableScheduledTasks: scheduledTaskOwner,
browserUse: createBrowserUseServiceOptions(options),
...(options.promptConfigKey
? { promptConfigKey: options.promptConfigKey }
Expand Down
56 changes: 56 additions & 0 deletions packages/local-runtime-v2/src/service/cron/ownership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ownsElectronRuntimeCapabilities } from "../../application/agent/runtime-browser-use-composition.js";

/** Ownership inputs for the process-local scheduling capability. */
export interface ScheduledTaskRuntimeOwnership {
readonly runtimeOwnerKind?: string;
/** Frozen host option: `true` requests an owned Scheduler plus `services.cron`. */
readonly enableScheduledTasks?: boolean;
}

/**
* Cron-specific ownership predicate. Electron owners, and resident hosts that
* explicitly opt in with `enableScheduledTasks`, own the in-process Scheduler
* and the Cron service. Every other host keeps the previous behavior.
*
* Kept separate from the `enableChannel` predicate on purpose: that call site
* shares `ownsElectronRuntimeCapabilities` with cron today, and widening it
* would open IM channel delivery as a side effect. This predicate layers the
* opt-in on top of the unchanged Electron rule instead of relaxing it.
*/
export function ownsScheduledTaskRuntime(options: ScheduledTaskRuntimeOwnership): boolean {
return (
ownsElectronRuntimeCapabilities(options.runtimeOwnerKind) ||
options.enableScheduledTasks === true
);
}

/** Scheduling inputs resolved once during host assembly. */
export interface ScheduledTaskSchedulingInput {
readonly electronOwner: boolean;
/** Startup execution policy decides persisted execution for existing owners. */
readonly startupExecutionEnabled: boolean;
readonly enableScheduledTasks?: boolean;
}

export interface ScheduledTaskScheduling {
/** Assemble the in-process Scheduler; false omits it entirely. */
readonly schedulerOwned: boolean;
/** Arm croner timers for persisted jobs instead of refreshing them for inspection. */
readonly restorePersistedJobExecution: boolean;
}

/**
* Resolves background-runtime scheduling for one host. A host that opted into
* scheduled tasks always restores persisted job execution, because a resident
* host exists to run its schedule; every other host keeps the value derived
* from the startup execution policy.
*/
export function resolveScheduledTaskScheduling(
input: ScheduledTaskSchedulingInput,
): ScheduledTaskScheduling {
const scheduledTaskOwner = input.enableScheduledTasks === true;
return {
schedulerOwned: input.electronOwner || scheduledTaskOwner,
restorePersistedJobExecution: scheduledTaskOwner || input.startupExecutionEnabled,
};
}
122 changes: 121 additions & 1 deletion packages/local-runtime-v2/src/services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import type { AppDb } from "./infra/db/client.js";
import { readPreferenceValue } from "./infra/db/preference-values.js";
import { migratePluginTestDatabase } from "../test/helpers/plugin-database.js";
import { EventBus } from "./infra/event-bus/index.js";
import type { SchedulerClient } from "./infra/scheduler/index.js";
import { createBackgroundRuntime } from "./background-runtime.js";
import { Scheduler, type SchedulerClient } from "./infra/scheduler/index.js";
import { resolveScheduledTaskScheduling } from "./service/cron/ownership.js";
import type { LocalAgentService } from "./service/agent/index.js";
import { resolveAgentPromptSurface } from "./service/turn-system/agent-host/preparation/agent-prompt-surface.js";
import type { InitializeTurnSystemOptions } from "./service/turn-system/index.js";
Expand Down Expand Up @@ -2056,6 +2058,124 @@ describe("runtime services CLI composition", () => {
expect(mocked.events.at(-1)).toBe("agent:ensure");
});

it("composes the Cron service for a resident host that opts into scheduled tasks", async () => {
const scheduler = {} as SchedulerClient;
const services = await createRuntimeServices({
db: {} as AppDb,
dataDir: "/data/scheduled-tasks",
logger: noopLogger,
scheduler,
eventBus: new EventBus<GlobalEvent>(),
compatibility: defaultCompatibility(),
agentService: localAgentService,
runtimeOwnerKind: "tui",
capabilityProfile: "cli",
enableScheduledTasks: true,
});

expect(services.cron).toBe(mocked.service);
expect(mocked.cronOptions?.scheduler).toBe(scheduler);
// The opt-in is Cron-scoped: channel delivery keeps following its own owner rule.
expect(services.channelSystem).toBeUndefined();
expect(services.cronDelivery).toBeDefined();
await services.close();
});

it("leaves a resident host unchanged when the scheduled-task option is absent or false", async () => {
const resident = await createRuntimeServices({
db: {} as AppDb,
dataDir: "/data/resident-without-opt-in",
logger: noopLogger,
scheduler: {} as SchedulerClient,
eventBus: new EventBus<GlobalEvent>(),
compatibility: defaultCompatibility(),
agentService: localAgentService,
runtimeOwnerKind: "tui",
capabilityProfile: "cli",
});
expect(resident.cron).toBeUndefined();
expect(resident.cronDelivery).toBeUndefined();
expect(resident.channelSystem).toBeUndefined();
expect(mocked.cronOptions).toBeUndefined();
await resident.close();

mocked.cronOptions = undefined;
const explicitlyDisabled = await createRuntimeServices({
db: {} as AppDb,
dataDir: "/data/resident-opt-out",
logger: noopLogger,
scheduler: {} as SchedulerClient,
eventBus: new EventBus<GlobalEvent>(),
compatibility: defaultCompatibility(),
agentService: localAgentService,
runtimeOwnerKind: "tui",
capabilityProfile: "cli",
enableScheduledTasks: false,
});
expect(explicitlyDisabled.cron).toBeUndefined();
expect(explicitlyDisabled.channelSystem).toBeUndefined();
expect(mocked.cronOptions).toBeUndefined();
await explicitlyDisabled.close();

// Electron owners keep their pre-existing Cron capability without the option.
mocked.cronOptions = undefined;
const electron = await createRuntimeServices({
db: {} as AppDb,
dataDir: "/data/electron-default",
logger: noopLogger,
scheduler: {} as SchedulerClient,
eventBus: new EventBus<GlobalEvent>(),
compatibility: defaultCompatibility(),
agentService: localAgentService,
runtimeOwnerKind: "electron",
});
expect(electron.cron).toBe(mocked.service);
await electron.close();
});

it("starts the Scheduler with persisted job execution for an opted-in host", async () => {
const scheduling = resolveScheduledTaskScheduling({
electronOwner: false,
startupExecutionEnabled: false,
enableScheduledTasks: true,
});
expect(scheduling).toEqual({
schedulerOwned: true,
restorePersistedJobExecution: true,
});

const start = vi
.spyOn(Scheduler.prototype, "start")
.mockImplementation(() => undefined);
try {
const background = await createBackgroundRuntime({
db: {} as AppDb,
enableScheduler: scheduling.schedulerOwned,
restorePersistedJobExecution: scheduling.restorePersistedJobExecution,
});
await background.start();

expect(start).toHaveBeenCalledWith({ restorePersistedJobExecution: true });
await background.close();
} finally {
start.mockRestore();
}

// Without the opt-in the resident host keeps the policy-derived value.
expect(
resolveScheduledTaskScheduling({
electronOwner: false,
startupExecutionEnabled: false,
}),
).toEqual({ schedulerOwned: false, restorePersistedJobExecution: false });
expect(
resolveScheduledTaskScheduling({
electronOwner: true,
startupExecutionEnabled: false,
}),
).toEqual({ schedulerOwned: true, restorePersistedJobExecution: false });
});

/**
* The Inspector is composed on a test build, so composition must tolerate a
* product that carries no model resolver at all. Reading `.fetchImpl` off an
Expand Down
8 changes: 7 additions & 1 deletion packages/local-runtime-v2/src/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ import {
type CronTurnDeliveryPort,
type InitializedCronService,
} from "./service/cron/index.js";
import { ownsScheduledTaskRuntime } from "./service/cron/ownership.js";
import {
createRuntimeInspector,
type ComposedInspector,
Expand Down Expand Up @@ -307,6 +308,8 @@ export interface CreateRuntimeServicesOptions
readonly recoverPersistedState?: boolean;
/** Composition owner mode; CLI omits Electron-only capabilities. */
readonly runtimeOwnerKind?: string;
/** v2 host option: request ownership of scheduling (in-process timers + Cron service assembly). */
readonly enableScheduledTasks?: boolean;
/** Electron-owned fixed key for encrypted Desktop Prompt bundles. */
readonly promptConfigKey?: Uint8Array;
/** Client capability ceiling; omitted owners retain the shared legacy surface. */
Expand Down Expand Up @@ -490,7 +493,10 @@ export async function createRuntimeServices(
internalTurnPromptReads,
writeGlobalEvent,
nowMs,
enableCron: ownsElectronRuntimeCapabilities(options.runtimeOwnerKind),
enableCron: ownsScheduledTaskRuntime({
runtimeOwnerKind: options.runtimeOwnerKind,
enableScheduledTasks: options.enableScheduledTasks,
}),
runtimeOwnerIdentity: options.runtimeOwnerIdentity,
planEntryEnabled,
agentPlanEntryEnabled,
Expand Down
Loading
Loading