Skip to content

feat(server): server-side scheduled tasks — start agent runs automatically - #7986

Open
ImBIOS wants to merge 2 commits into
pingdotgg:mainfrom
ImBIOS:feat/scheduled-tasks-upstream
Open

feat(server): server-side scheduled tasks — start agent runs automatically#7986
ImBIOS wants to merge 2 commits into
pingdotgg:mainfrom
ImBIOS:feat/scheduled-tasks-upstream

Conversation

@ImBIOS

@ImBIOS ImBIOS commented Aug 23, 2026

Copy link
Copy Markdown

Fixes #7966

Problem

T3 Code only starts agent runs when the user sends a message. Time-shaped workflows — nightly test runs, delayed follow-ups, recurring chores — have no primitive. Thread snooze is visibility-only and its wake is client-derived, so nothing happens if no client is open or the server restarts.

Fix

Event-sourced scheduled tasks that start turns on an existing thread:

  • Contracts: task.schedule / task.cancel commands (client-dispatchable), server-internal task.fire, events task.scheduled / task.fired / task.cancelled on a new task aggregate kind. Schedule spec v1: one-shot at or interval everyMs (≥ 1 min).
  • Decider: validates future first-fire times and duplicate ids; fire guard rejects early/duplicate/cancelled fires. Interval slots advance from the previous due slot (never "now"), so schedules never drift and downtime coalesces into one fire landing on the first future slot.
  • Scheduler (TaskSchedulerLive, modeled on ProviderSessionReaper): 15s tick finds due tasks in the projection and dispatches task.fire with deterministic commandIds (server:task-fire:<taskId>:<nextFireAt>) — crash retries collapse into the existing idempotent command receipts.
  • Reactor (TaskFireReactor): consumes task.fired and dispatches a normal thread.turn.start to the anchor thread with the task prompt; modes ride on the event payload so no read-model lookup is needed.
  • Read model: projection_tasks table (migration 042), projector cases, listTasks/listDueTasks queries, orchestration.listTasks RPC, taskScheduling capability flag.
  • Web: create/cancel/list section under Settings → Projects (capability-gated). Mobile inherits commands via client-runtime automatically; screens can follow.

Deliberately out of scope for v1: cron syntax, unanchored tasks (auto-created threads need a default-model policy), event-based triggers.

Verification

  • New: decider.scheduled-tasks.test.ts (11 tests: schedule validation, drift-anchored fire math, downtime coalescing, once-spending, early-fire/cancelled rejection, idempotent cancel) and TaskScheduler.test.ts (deterministic fire ids, empty tick).
  • All touched-scope suites pass (decider/commandInvariants/reactor/pipeline/query/reaper/startup/server); targeted typecheck + lint clean on server, contracts, web.

ox-alpha (opencode CLI)


Note

Medium Risk
Touches core orchestration (decider, projections, startup) and can auto-dispatch agent turns, though behavior is gated by settings, idempotent fire commands, and extensive new tests.

Overview
Adds event-sourced scheduled tasks so the server can start agent turns on an existing thread without a user message — one-shot or interval schedules, cancel, and persistence across restarts.

Domain & persistence: New task aggregate with task.schedule / task.cancel (clients) and internal task.fire, plus task.scheduled / task.fired / task.cancelled events. The decider enforces future first-fire times, idempotent cancel, due-time guards, and interval math that anchors to prior slots and coalesces missed ticks. Read model gains tasks, SQLite projection_tasks (migration 042), projector cases, and listTasks / listDueTasks. Event store and command receipts accept TaskId streams.

Runtime: TaskScheduler ticks (~15s), respects server enableScheduledTasks, and dispatches task.fire with deterministic command IDs for idempotent retries. TaskFireReactor turns task.fired into thread.turn.start on the anchor thread. Both join orchestration startup alongside existing reactors. orchestration.listTasks RPC and taskScheduling capability advertise support; RPC auth maps listTasks to orchestration read scope.

Clients: Web gets Integrations Automation toggle and per-project Scheduled tasks UI (schedule/cancel/list); client-runtime adds schedule/cancel commands and a scheduled-tasks query atom.

Reviewed by Cursor Bugbot for commit d9b0640. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add server-side scheduled tasks that auto-start agent runs

  • Introduces task.schedule, task.cancel, and internal task.fire commands with full event-sourced lifecycle (task.scheduled, task.fired, task.cancelled) in contracts, decider, and projector
  • TaskScheduler runs a periodic tick (default 15s) that queries due tasks and dispatches deterministic task.fire commands; TaskFireReactor consumes task.fired events and starts a new thread.turn.start turn on the anchor thread using the task's prompt
  • Adds projection_tasks table (migration 42), repository, projection pipeline, and snapshot query APIs (listTasks, listDueTasks) with a partial index on next_fire_at for efficient due-task lookups
  • Web UI adds a Settings toggle (enableScheduledTasks, default true), a per-project ScheduledTasksSection for scheduling/cancelling tasks, and client command executors (scheduleTask, cancelTask) with serial per-task concurrency
  • New orchestration.listTasks WebSocket RPC requires the orchestration read scope and returns tasks for a project
  • Risk: enableScheduledTasks defaults to true, so the scheduler starts firing on existing servers after deploy; verify migration 042 (projection_tasks) applies cleanly and the partial index idx_projection_tasks_next_fire_at is created

Macroscope summarized d9b0640.

…cally

Adds task.schedule / task.cancel commands, a scheduler layer that fires due
tasks through the orchestration engine with deterministic idempotent command
ids, and a reactor that turns each fire into a normal thread.turn.start on
the anchor thread. Includes projection table + queries, listTasks RPC, web
settings panel section, and docs.

Fixes pingdotgg#7966
@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f9ab58a6-2553-4c89-90e3-983e6e98c8b7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 23, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UI consistency review of the new ScheduledTasksSection settings panel. Four findings, all in apps/web/src/components/settings/ScheduledTasksSection.tsx: two Select triggers render the raw stored value instead of a label (the repo's documented SelectValue contract), the two triggers and the prompt input are missing the accessible names every other settings control provides, the trigger widths break the w-full sm:w-* responsive pattern used by sibling rows, and the Schedule button stays enabled in a state where submitting is a silent no-op.

Posted via Macroscope — UI Consistency

Comment on lines +207 to +213
<Button
size="sm"
onClick={() => void submit()}
disabled={submitting || prompt.trim().length === 0}
>
Schedule
</Button>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

submit() returns early when anchorThread is undefined (the initial threadKey is captured before threads load, and it is never reconciled when the thread list changes), but the button stays enabled — the user gets a live control that does nothing and no feedback. Consider reflecting that precondition in disabled.

Suggested change
<Button
size="sm"
onClick={() => void submit()}
disabled={submitting || prompt.trim().length === 0}
>
Schedule
</Button>
<Button
size="sm"
onClick={() => void submit()}
disabled={submitting || !anchorThread || prompt.trim().length === 0}
>
Schedule
</Button>

Posted via Macroscope — UI Consistency

Comment on lines +181 to +183
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same SelectValue issue here: with options rendered inline the trigger displays the raw preset key (tomorrow-9) rather than PRESET_LABELS[preset]. Passing the label as children (and adding the aria-label / responsive width the other settings selects use) restores the intended text.

Suggested change
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectTrigger className="w-full sm:w-56" aria-label="Scheduled task schedule">
<SelectValue>{PRESET_LABELS[preset]}</SelectValue>
</SelectTrigger>

Posted via Macroscope — UI Consistency

Comment on lines +159 to +162
<Select value={threadKey} onValueChange={(value) => setThreadKey(String(value))}>
<SelectTrigger className="w-64">
<SelectValue />
</SelectTrigger>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bare SelectValue with items built inline (no items map on the Select root) prints the raw stored value, so this trigger shows env-id:thread-id instead of the thread title — see the comment on viewportSelectLabel in IntegrationsSettings.tsx. Every other settings SelectTrigger also carries an aria-label and uses the w-full sm:w-* width so the control does not stay fixed-width on narrow viewports.

Suggested change
<Select value={threadKey} onValueChange={(value) => setThreadKey(String(value))}>
<SelectTrigger className="w-64">
<SelectValue />
</SelectTrigger>
<Select value={threadKey} onValueChange={(value) => setThreadKey(String(value))}>
<SelectTrigger className="w-full sm:w-64" aria-label="Scheduled task thread">
<SelectValue>{anchorThread?.title ?? "Select a thread"}</SelectValue>
</SelectTrigger>

Posted via Macroscope — UI Consistency

description="Sent as the user message for each run."
control={
<div className="flex w-full max-w-xl items-center gap-2">
<Input

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SettingsRow's title renders as an h3, not a <label>, so this input has no accessible name beyond its placeholder. Other settings inputs (e.g. the project name field in ProjectSettingsPanel.tsx) pass an explicit aria-label.

Suggested change
<Input
<Input
aria-label="Scheduled task prompt"

Posted via Macroscope — UI Consistency

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effect service conventions: 4 findings on the newly introduced task services. The three new services are defined in the legacy Services/ + Layers/ split with standalone *Shape interfaces and *Live layer names, instead of the canonical single-module form (inline interface in Context.Service, exported make, exported layer) already used by e.g. apps/server/src/persistence/AuthSessions.ts. One error-construction site copies cause.message into a structural field.

Posted via Macroscope — Effect Service Conventions

Comment on lines +2421 to +2431
Effect.try({
try: () => parseScheduleJson(row.taskId, row.scheduleJson),
catch: (cause) =>
new PersistenceDecodeError({
operation: "ProjectionSnapshotQuery.decodeTaskRows:scheduleJson",
issue: cause instanceof Error ? cause.message : String(cause),
...(cause !== undefined ? { cause } : {}),
}),
}).pipe(
Effect.flatMap((schedule) =>
decodeTaskSchema({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue here just copies the message of an Error manufactured in parseScheduleJson, and PersistenceDecodeError.message is then derived from that string — the wrapper message ends up derived from cause.message rather than from structural attributes. Consider decoding the schedule with the Effect variant and mapping the real SchemaError, so issue comes from the schema issue and the underlying error is preserved as cause:

const decodeTaskScheduleJson = Schema.decodeUnknownEffect(Schema.fromJsonString(TaskScheduleSpec));

// in decodeTaskRows
decodeTaskScheduleJson(row.scheduleJson).pipe(
  Effect.mapError(
    toPersistenceDecodeError("ProjectionSnapshotQuery.decodeTaskRows:scheduleJson"),
  ),
)

The task id can travel in the operation/correlation context instead of being baked into a synthetic message.

Posted via Macroscope — Effect Service Conventions

Comment on lines +47 to +86
/**
* ProjectionTaskRepositoryShape - Service API for projected task records.
*/
export interface ProjectionTaskRepositoryShape {
/**
* Insert or replace a projected task row.
*
* Upserts by `taskId`.
*/
readonly upsert: (task: ProjectionTask) => Effect.Effect<void, ProjectionRepositoryError>;

/**
* Read a projected task row by id.
*/
readonly getById: (
input: GetProjectionTaskInput,
) => Effect.Effect<Option.Option<ProjectionTask>, ProjectionRepositoryError>;

/**
* List projected tasks for a project.
*/
readonly listByProjectId: (
input: ListProjectionTasksByProjectInput,
) => Effect.Effect<ReadonlyArray<ProjectionTask>, ProjectionRepositoryError>;

/**
* Delete projected task state by id.
*/
readonly deleteById: (
input: DeleteProjectionTaskInput,
) => Effect.Effect<void, ProjectionRepositoryError>;
}

/**
* ProjectionTaskRepository - Service tag for task persistence.
*/
export class ProjectionTaskRepository extends Context.Service<
ProjectionTaskRepository,
ProjectionTaskRepositoryShape
>()("t3/persistence/Services/ProjectionTasks/ProjectionTaskRepository") {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New repository service uses a standalone ProjectionTaskRepositoryShape plus a Services/ + Layers/ split and a ProjectionTaskRepositoryLive layer name. Consider following the already-migrated persistence modules (apps/server/src/persistence/AuthSessions.ts, ProviderSessionRuntime.ts): one module apps/server/src/persistence/ProjectionTasks.ts holding the schemas, the Context.Service tag with the interface inline, make, and export const layer = Layer.effect(ProjectionTaskRepository, make) — no *Shape type and no *Live alias.

Posted via Macroscope — Effect Service Conventions

Comment on lines +5 to +19
export interface TaskFireReactorShape {
/**
* Start consuming task.fired events within the provided scope.
*/
readonly start: () => Effect.Effect<void, never, Scope.Scope>;

/**
* Wait until every enqueued task.fired event has been processed.
*/
readonly drain: Effect.Effect<void>;
}

export class TaskFireReactor extends Context.Service<TaskFireReactor, TaskFireReactorShape>()(
"t3/orchestration/Services/TaskFireReactor",
) {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same convention issue as TaskScheduler: this new service keeps a standalone TaskFireReactorShape and lives in Services/ with its construction in Layers/. Consider collapsing both into apps/server/src/orchestration/TaskFireReactor.ts with the interface inline in the Context.Service declaration, an exported make, and export const layer = Layer.effect(TaskFireReactor, make); reference the shape as TaskFireReactor["Service"] in the implementation and in the harness/test layers that stub it.

Posted via Macroscope — Effect Service Conventions

Comment on lines +5 to +21
export interface TaskSchedulerShape {
/**
* Start the background scheduler tick loop within the provided scope.
*/
readonly start: () => Effect.Effect<void, never, Scope.Scope>;

/**
* Run one tick immediately: dispatch `task.fire` for every armed task whose
* nextFireAt has passed. Returns the number of fires dispatched. Exposed as
* a deterministic seam for tests — never wait on the wall-clock loop.
*/
readonly tick: () => Effect.Effect<number, never>;
}

export class TaskScheduler extends Context.Service<TaskScheduler, TaskSchedulerShape>()(
"t3/orchestration/Services/TaskScheduler",
) {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New service is split across Services/TaskScheduler.ts + Layers/TaskScheduler.ts with a standalone TaskSchedulerShape. Since this is new code, consider using the canonical single-module form: apps/server/src/orchestration/TaskScheduler.ts containing (in order) imports, the Context.Service tag with the interface declared inline, make, then export const layer = Layer.effect(TaskScheduler, make).

Concretely:

  • drop TaskSchedulerShape and refer to the inferred shape as TaskScheduler["Service"]["tick"] / ["start"] in Layers/TaskScheduler.ts;
  • rename makeTaskSchedulerLive/TaskSchedulerLive to make/layer (the options-taking variant can stay as layerWith(options) or make(options));
  • update the consumers (server.ts, serverRuntimeStartup.ts, TaskScheduler.test.ts, orphanedProviderSessionStartup.integration.test.ts) to the canonical path.

Posted via Macroscope — Effect Service Conventions

threadId: row.threadId,
...(row.name !== null ? { name: row.name } : {}),
prompt: row.prompt,
schedule: parseScheduleJson(row.taskId, row.scheduleJson),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High Layers/ProjectionSnapshotQuery.ts:2053

getCommandReadModel turns an invalid persisted schedule_json into an Effect defect, so one malformed task can terminate orchestration engine startup instead of returning the declared PersistenceDecodeError. parseScheduleJson is called inside Effect.sync, and the later Effect.mapError does not handle defects; reuse decodeTaskRows (or wrap parsing with Effect.try) here.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts around line 2053:

`getCommandReadModel` turns an invalid persisted `schedule_json` into an Effect defect, so one malformed task can terminate orchestration engine startup instead of returning the declared `PersistenceDecodeError`. `parseScheduleJson` is called inside `Effect.sync`, and the later `Effect.mapError` does not handle defects; reuse `decodeTaskRows` (or wrap parsing with `Effect.try`) here.

</SettingsSection>

<ScheduledTasksSection
environmentId={representative.environmentId}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium settings/ProjectSettingsPanel.tsx:1149

ScheduledTasksSection always loads and mutates tasks for representative, so selecting another checkout leaves its tasks invisible and prevents scheduling actions for it; the section is also hidden when only a non-representative checkout supports scheduling. Use selectedCheckout for the environment, project, and filtered threads.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/ProjectSettingsPanel.tsx around line 1149:

`ScheduledTasksSection` always loads and mutates tasks for `representative`, so selecting another checkout leaves its tasks invisible and prevents scheduling actions for it; the section is also hidden when only a non-representative checkout supports scheduling. Use `selectedCheckout` for the environment, project, and filtered threads.


const start: TaskFireReactorShape["start"] = Effect.fn("start")(function* () {
yield* forkParked(
Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High Layers/TaskFireReactor.ts:70

Committed task.fired events are permanently lost if the server crashes before this live streamDomainEvents subscriber processes them, so the scheduled agent turn is skipped even though nextFireAt has advanced. Additionally, processTaskFiredSafely swallows every non-interruption dispatch failure, causing transient engine or persistence errors to complete the drain item without retry. Use a durable/replayed cursor or equivalent redelivery path, and only suppress the expected deleted-anchor error (or retry other failures).

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/TaskFireReactor.ts around line 70:

Committed `task.fired` events are permanently lost if the server crashes before this live `streamDomainEvents` subscriber processes them, so the scheduled agent turn is skipped even though `nextFireAt` has advanced. Additionally, `processTaskFiredSafely` swallows every non-interruption `dispatch` failure, causing transient engine or persistence errors to complete the drain item without retry. Use a durable/replayed cursor or equivalent redelivery path, and only suppress the expected deleted-anchor error (or retry other failures).

case "tomorrow-9":
return { kind: "once", at: nextNineAm(now) };
case "daily-9":
return { kind: "interval", everyMs: DAY_MS };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High settings/ScheduledTasksSection.tsx:43

The daily-9 and weekly-mon-9 presets do not run at 09:00 or on Monday: they first fire one interval after scheduling, so a task created at 14:30 runs daily at 14:30 and weekly on the creation weekday. resolveSchedule returns only everyMs, leaving the server to anchor the recurrence to creation time; include an explicit next-fire anchor (or otherwise preserve the selected 09:00/Monday schedule) when constructing these schedules.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/ScheduledTasksSection.tsx around line 43:

The `daily-9` and `weekly-mon-9` presets do not run at 09:00 or on Monday: they first fire one interval after scheduling, so a task created at 14:30 runs daily at 14:30 and weekly on the creation weekday. `resolveSchedule` returns only `everyMs`, leaving the server to anchor the recurrence to creation time; include an explicit next-fire anchor (or otherwise preserve the selected 09:00/Monday schedule) when constructing these schedules.

return [unsettledEvent, activityAppendedEvent];
}

case "task.schedule": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium orchestration/decider.ts:1416

Forced project.delete removes the anchor threads but leaves their active tasks armed. Those tasks later reach task.fire, fail because the anchor thread is deleted, and remain due forever, causing repeated scheduler retries. Cancel all project tasks as part of the forced deletion sequence before deleting the project.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/decider.ts around line 1416:

Forced `project.delete` removes the anchor threads but leaves their active tasks armed. Those tasks later reach `task.fire`, fail because the anchor thread is deleted, and remain due forever, causing repeated scheduler retries. Cancel all project tasks as part of the forced deletion sequence before deleting the project.

command,
threadId: command.threadId,
});
if (anchorThread.deletedAt !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium orchestration/decider.ts:1427

task.schedule can create a task with command.projectId while anchoring it to a thread from a different project, so the task is listed under one project but its scheduled turns run on another project's thread. After requireThread, reject the command when anchorThread.projectId !== command.projectId.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/decider.ts around line 1427:

`task.schedule` can create a task with `command.projectId` while anchoring it to a thread from a different project, so the task is listed under one project but its scheduled turns run on another project's thread. After `requireThread`, reject the command when `anchorThread.projectId !== command.projectId`.

## Cancel a task

Open **Settings → Projects → Scheduled tasks** and select **Cancel** next to the task.
Cancelled tasks stay visible until the panel is refreshed elsewhere.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low user/scheduled-tasks.md:24

The guide falsely tells users that cancelled tasks disappear when the panel is refreshed, but listTasks returns them and ScheduledTasksSection renders a “cancelled” label, so refresh never removes these entries. Remove this sentence or state that cancellation only stops future runs.

Suggested change
Cancelled tasks stay visible until the panel is refreshed elsewhere.
Cancelled tasks remain visible with a “cancelled” label after cancellation.
🤖 Copy this AI Prompt to have your agent fix this:
In file @docs/user/scheduled-tasks.md around line 24:

The guide falsely tells users that cancelled tasks disappear when the panel is refreshed, but `listTasks` returns them and `ScheduledTasksSection` renders a “cancelled” label, so refresh never removes these entries. Remove this sentence or state that cancellation only stops future runs.

snapshotSequence: computeSnapshotSequence(stateRows),
projects,
threads,
tasks: yield* decodeTaskRows(taskRows),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Layers/ProjectionSnapshotQuery.ts:1818

getSnapshot can advertise a snapshotSequence newer than the task data it returns, so consumers may resume after that sequence and permanently miss task updates while the task projector is lagging. computeSnapshotSequence only considers REQUIRED_SNAPSHOT_PROJECTORS, which omits ORCHESTRATION_PROJECTOR_NAMES.tasks; add the task projector to that set so both getSnapshot and getCommandReadModel wait for task state to reach the advertised watermark.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts around line 1818:

`getSnapshot` can advertise a `snapshotSequence` newer than the task data it returns, so consumers may resume after that sequence and permanently miss task updates while the task projector is lagging. `computeSnapshotSequence` only considers `REQUIRED_SNAPSHOT_PROJECTORS`, which omits `ORCHESTRATION_PROJECTOR_NAMES.tasks`; add the task projector to that set so both `getSnapshot` and `getCommandReadModel` wait for task state to reach the advertised watermark.

return { kind: "interval", everyMs: DAY_MS };
case "weekly-mon-9":
return { kind: "interval", everyMs: 7 * DAY_MS };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Daily presets ignore 9:00

High Severity

The daily-9 and weekly-mon-9 presets label and document wall-clock times at 9:00, but resolveSchedule only sends an interval with everyMs. The decider then sets the first fire to schedule-time plus the interval, so recurring runs land at the create time of day, not 9:00 or Monday morning.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6f33ca5. Configure here.

return yield* new OrchestrationCommandInvariantError({
commandType: command.type,
detail: `task ${command.taskId} anchor thread '${task.threadId}' is deleted and cannot fire`,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted thread sticks forever

High Severity

When task.fire rejects because the anchor thread is deleted, nextFireAt never advances and the engine stores a rejected receipt for the deterministic server:task-fire:&lt;taskId&gt;:&lt;nextFireAt&gt; id. Later ticks keep hitting that receipt, so the task stays due forever with no fire and only cancel clears it.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6f33ca5. Configure here.

cause: Cause.pretty(cause),
});
}),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed turns drop forever

High Severity

After a successful task.fired, nextFireAt already advanced, but the reactor only listens to live PubSub and swallows thread.turn.start failures with no retry or startup reconciliation. A crash or transient dispatch error between fire and turn permanently skips that run.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6f33ca5. Configure here.

commandType: command.type,
detail: `Thread '${command.threadId}' is deleted and cannot anchor task '${command.taskId}'.`,
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-project task anchors allowed

Medium Severity

task.schedule requires the project and thread to exist but never checks that the thread belongs to that project. A client can attach a task under one project while anchoring fires to another project's thread, so listing and ownership diverge.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6f33ca5. Configure here.

@macroscopeapp

macroscopeapp Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR adds a new persisted scheduling workflow that autonomously starts agent runs through a background server reactor, along with schema, RPC, and UI changes. Its broad runtime impact and unresolved concerns around fire delivery, recurrence timing, and environment/project scoping warrant human review.

Not approved because:

  • 9 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

The scheduler fired whenever tasks existed, with no way to turn the
behavior off short of cancelling every task. Adds an
enableScheduledTasks ServerSettings flag (default on): the tick reads
it per cycle and skips the due-scan when off, stored schedules are
untouched and resume with normal coalescing when re-enabled, and the
per-project schedule UI hides while the switch is off.

Surfaces as Settings -> Integrations -> Automation -> Scheduled tasks,
following the enableAgentBrowserAccess pattern end-to-end: contracts
schema + patch key, per-tick server gate, web toggle with search entry
and restore-defaults wiring, and user docs.
@ImBIOS

ImBIOS commented Aug 25, 2026

Copy link
Copy Markdown
Author

Added a master switch for the feature: enableScheduledTasks in ServerSettings (d9b0640).

  • Settings → Integrations → Automation toggle (web); per-project schedule section hides while off.
  • Server-side gate in TaskScheduler.tick — reads the flag per tick, so flipping it takes effect within one interval without a restart. Stored tasks are untouched; missed fires coalesce per the existing drift anchoring when re-enabled.
  • Follows the enableAgentBrowserAccess pattern: contracts schema + patch key, restore-defaults wiring, settings search entry, docs.

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at d9b0640. apps/web/src/components/settings/ScheduledTasksSection.tsx is unchanged since my previous review, so the four findings from that review still stand (raw SelectValue printing the stored value on both triggers, missing aria-label/responsive width on the triggers and prompt input, and the Schedule button staying enabled when no anchor thread resolves). I have not re-posted them. One additional layout finding is noted inline.

Posted via Macroscope — UI Consistency


return (
<SettingsSection title="Scheduled tasks">
<p className="text-sm text-muted-foreground">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Free-standing text and lists inside a SettingsSection need the row gutter: SettingsSection renders children with no horizontal padding, and SettingsRow supplies its own px-3 sm:px-4. Sibling paragraphs elsewhere follow that (e.g. ProjectSettingsPanel.tsx's "No actions configured for this checkout." uses px-3 ... sm:px-4), so these paragraphs and the task <ul> below sit flush to the panel edge and misalign with the rows above them. Same gutter is needed on the empty-state paragraph (line 219) and the <ul> (line 224).

Suggested change
<p className="text-sm text-muted-foreground">
<p className="px-3 text-sm text-muted-foreground sm:px-4">

Posted via Macroscope — UI Consistency

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

There are 6 total unresolved issues (including 4 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d9b0640. Configure here.

Effect.catchCause((cause) =>
Effect.logWarning("task.scheduler.settings-read-failed", { cause }).pipe(Effect.as(true)),
),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scheduler kill switch fails open

Medium Severity

TaskScheduler.tick treats a failed getSettings read as enableScheduledTasks: true via Effect.catchCause, so a settings/secrets read error keeps firing due tasks even after the user turned the switch off. That is the opposite of enableAgentBrowserAccess, which fails closed with Effect.catch so an explicit off cannot silently become on. catchCause also recovers interruptions, so a shutting-down tick can keep dispatching.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d9b0640. Configure here.

thread.projectId === representative.id,
)}
/>
) : null}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong env gates schedule UI

Medium Severity

The new enableScheduledTasks gate reads usePrimarySettings(), but ScheduledTasksSection is rendered for representative.environmentId. In a multi-environment workspace, primary off hides (or primary on shows) the schedule UI for a different server whose own flag and scheduler still control firing, so users can lose cancel/create UI while tasks keep running, or schedule tasks that never fire.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d9b0640. Configure here.

) : null}
</SettingsSection>

{settings.enableScheduledTasks ? (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High settings/ProjectSettingsPanel.tsx:1148

ScheduledTasksSection is shown or hidden using the primary server's settings.enableScheduledTasks, even when the section targets representative.environmentId. This exposes scheduling for remote servers whose scheduler will not run the stored tasks, and hides it for remote servers where scheduling is enabled; use the target environment's server settings for this guard.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/ProjectSettingsPanel.tsx around line 1148:

`ScheduledTasksSection` is shown or hidden using the primary server's `settings.enableScheduledTasks`, even when the section targets `representative.environmentId`. This exposes scheduling for remote servers whose scheduler will not run the stored tasks, and hides it for remote servers where scheduling is enabled; use the target environment's server settings for this guard.

Evidence trail:
Reviewed commit d9b0640. `apps/web/src/components/settings/ProjectSettingsPanel.tsx:291-305,1148-1157` (`usePrimarySettings()` controls a section targeting `representative.environmentId`). `apps/web/src/hooks/useSettings.ts:292-305` (`useEnvironmentSettings` is environment-scoped; `usePrimarySettings` reads the primary server). `apps/web/src/state/server.ts:74-76` (`primaryServerSettingsAtom`). `apps/web/src/components/settings/ScheduledTasksSection.tsx:73-99` (target environment/capability handling, but no target settings guard). `packages/contracts/src/settings.ts:620-631` (disabled setting stops dispatch and clients should hide the UI). `apps/server/src/orchestration/Layers/TaskScheduler.ts:37-50` (disabled server scheduler returns without dispatching). Verify with `git show d9b0640 -- apps/web/src/components/settings/ProjectSettingsPanel.tsx apps/web/src/hooks/useSettings.ts apps/web/src/state/server.ts apps/web/src/components/settings/ScheduledTasksSection.tsx packages/contracts/src/settings.ts apps/server/src/orchestration/Layers/TaskScheduler.ts`.

const nowIso = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
// A failed due-scan must not kill the loop: log and retry next tick.
const dueTasks = yield* projectionSnapshotQuery
.listDueTasks(nowIso)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Layers/TaskScheduler.ts:58

Tasks with offset-based at values fire late because listDueTasks(nowIso) compares the persisted ISO strings lexically against normalized UTC nowIso; for example, 2026-08-25T14:00:00+02:00 sorts after 2026-08-25T12:00:00.000Z and is not selected until roughly 14:00Z. Normalize persisted due times or compare parsed instants instead.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/TaskScheduler.ts around line 58:

Tasks with offset-based `at` values fire late because `listDueTasks(nowIso)` compares the persisted ISO strings lexically against normalized UTC `nowIso`; for example, `2026-08-25T14:00:00+02:00` sorts after `2026-08-25T12:00:00.000Z` and is not selected until roughly `14:00Z`. Normalize persisted due times or compare parsed instants instead.

Evidence trail:
Commit d9b0640. `apps/server/src/orchestration/Layers/TaskScheduler.ts:52-58`; `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts:511-533`; `apps/server/src/orchestration/decider.ts:1445-1449`; `packages/contracts/src/orchestration.ts:434-437`. Verify with `git show d9b0640 -- apps/server/src/orchestration/Layers/TaskScheduler.ts apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts apps/server/src/orchestration/decider.ts packages/contracts/src/orchestration.ts`.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Server-side scheduled tasks — start agent runs automatically (one-shot + recurring)

1 participant